pass-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.
- pass_mcp-1.0.0/PKG-INFO +11 -0
- pass_mcp-1.0.0/README.md +112 -0
- pass_mcp-1.0.0/pass_mcp/__init__.py +5 -0
- pass_mcp-1.0.0/pass_mcp/api.py +71 -0
- pass_mcp-1.0.0/pass_mcp/client.py +129 -0
- pass_mcp-1.0.0/pass_mcp/mcp_server.py +133 -0
- pass_mcp-1.0.0/pass_mcp/rate_limiter.py +59 -0
- pass_mcp-1.0.0/pass_mcp/schemas.py +56 -0
- pass_mcp-1.0.0/pass_mcp.egg-info/PKG-INFO +11 -0
- pass_mcp-1.0.0/pass_mcp.egg-info/SOURCES.txt +17 -0
- pass_mcp-1.0.0/pass_mcp.egg-info/dependency_links.txt +1 -0
- pass_mcp-1.0.0/pass_mcp.egg-info/entry_points.txt +3 -0
- pass_mcp-1.0.0/pass_mcp.egg-info/requires.txt +6 -0
- pass_mcp-1.0.0/pass_mcp.egg-info/top_level.txt +1 -0
- pass_mcp-1.0.0/pyproject.toml +25 -0
- pass_mcp-1.0.0/setup.cfg +4 -0
- pass_mcp-1.0.0/tests/test_client.py +21 -0
- pass_mcp-1.0.0/tests/test_conformance.py +288 -0
- pass_mcp-1.0.0/tests/test_mcp_live_stdio.py +68 -0
pass_mcp-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pass-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Model Context Protocol (MCP) Server & Python Client for Apple & Google Wallet Pass Delegation
|
|
5
|
+
Author: Emmanuel Biolatiri
|
|
6
|
+
Requires-Dist: fastapi>=0.110.0
|
|
7
|
+
Requires-Dist: uvicorn[standard]>=0.28.0
|
|
8
|
+
Requires-Dist: mcp>=1.0.0
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: pydantic>=2.6.0
|
|
11
|
+
Requires-Dist: python-dotenv>=1.0.0
|
pass_mcp-1.0.0/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Pass-MCP (`pass-mcp`)
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
[](https://modelcontextprotocol.io/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
**`pass-mcp`** is an open-source **Model Context Protocol (MCP)** server and Python client for delegating and issuing **Apple Wallet (`.pkpass`)** and **Google Wallet** passes to AI agents (Claude Desktop, AutoGPT, LLMs).
|
|
8
|
+
|
|
9
|
+
Built with **FastAPI**, **Pydantic v2**, **httpx**, and Anthropic's official **`mcp` Python SDK**.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## โก Quickstart
|
|
14
|
+
|
|
15
|
+
### 1. Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
git clone https://github.com/emmanuelbiolatiri/pass-mcp.git
|
|
19
|
+
cd pass-mcp
|
|
20
|
+
|
|
21
|
+
# Create virtual environment
|
|
22
|
+
python3 -m venv venv
|
|
23
|
+
source venv/bin/activate
|
|
24
|
+
|
|
25
|
+
# Install in editable mode
|
|
26
|
+
pip install -e .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## ๐ Daily Free Pass Tier & Licensing
|
|
32
|
+
|
|
33
|
+
* **10 Free Passes / Day**: Every installer/device receives **10 free pass issuances per day** automatically out-of-the-box (`pass_mcp/rate_limiter.py`). No API key or credit card required!
|
|
34
|
+
* **Unlimited Production Tier**: Set your `WALLETKIT_API_KEY` environment variable to connect to your live `walletKit` merchant account for unlimited pass signing:
|
|
35
|
+
```env
|
|
36
|
+
WALLETKIT_API_KEY=wk_live_abc123...
|
|
37
|
+
WALLETKIT_API_URL=https://api.walletkit.io
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## ๐งช Testing
|
|
43
|
+
|
|
44
|
+
Run the automated test suite and over-the-wire conformance tests:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
./venv/bin/pytest -v
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## ๐ค Claude Desktop Configuration (`claude_desktop_config.json`)
|
|
53
|
+
|
|
54
|
+
Add `pass-mcp` to your Claude Desktop configuration file:
|
|
55
|
+
* **macOS Path**: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"pass-mcp": {
|
|
61
|
+
"command": "/Users/emmanuelbiolatiri/Desktop/manuel/pass-mcp/venv/bin/pass-mcp",
|
|
62
|
+
"env": {
|
|
63
|
+
"WALLETKIT_API_URL": "http://localhost:3000"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Restart Claude Desktop and prompt:
|
|
71
|
+
> *"Issue an event ticket pass under a delegation mandate for agent_123."*
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## ๐ Running as an HTTP / FastAPI Gateway (Port 9000)
|
|
76
|
+
|
|
77
|
+
You can also run `pass-mcp` as a standalone web gateway:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Start FastAPI gateway on port 9000
|
|
81
|
+
./venv/bin/uvicorn pass_mcp.api:app --port 9000 --reload
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Test endpoint via `curl`:
|
|
85
|
+
```bash
|
|
86
|
+
curl -X POST http://localhost:9000/api/v1/mandates/issue \
|
|
87
|
+
-H "Content-Type: application/json" \
|
|
88
|
+
-d '{
|
|
89
|
+
"principalId": "usr_human_demo",
|
|
90
|
+
"agentId": "agent_ai_demo",
|
|
91
|
+
"authorizationDetails": [
|
|
92
|
+
{
|
|
93
|
+
"type": "event_ticket",
|
|
94
|
+
"pass_class": "event_ticket",
|
|
95
|
+
"max_quantity": 2
|
|
96
|
+
}
|
|
97
|
+
]
|
|
98
|
+
}'
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## ๐ Security & Delegation Invariants
|
|
104
|
+
|
|
105
|
+
`pass-mcp` enforces the 7 core agent delegation security invariants:
|
|
106
|
+
1. **Principal Binding**: Issued passes bind strictly to human principal (`sub`), never to agent actor (`act`).
|
|
107
|
+
2. **Proof-of-Possession**: Enforces `cnf` key thumbprints.
|
|
108
|
+
3. **Strict Scope Evaluation**: Evaluated exclusively from signed JWT `authorization_details`.
|
|
109
|
+
4. **Idempotency Engine**: `SHA256(mandate_jti || canonical_request_hash)` prevents duplicate pass issuance.
|
|
110
|
+
5. **Immutable Audit Ledger**: Every decision path logs to an append-only, cryptographically hash-chained ledger.
|
|
111
|
+
6. **Fail-Closed Escalation**: CIBA human-in-the-loop timeouts fail closed (`DENY`).
|
|
112
|
+
7. **Standards Compliant**: Built on RFC 8693, CIBA, and Model Context Protocol (MCP).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import uvicorn
|
|
3
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
4
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
5
|
+
from pass_mcp.schemas import (
|
|
6
|
+
IssueMandateRequest,
|
|
7
|
+
EnforcePassRequest,
|
|
8
|
+
)
|
|
9
|
+
from pass_mcp.client import WalletKitPassClient
|
|
10
|
+
|
|
11
|
+
app = FastAPI(
|
|
12
|
+
title="Pass-MCP Python Gateway",
|
|
13
|
+
description="Python Gateway & MCP Server for Apple & Google Wallet Pass Agent Delegation",
|
|
14
|
+
version="1.0.0",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
app.add_middleware(
|
|
18
|
+
CORSMiddleware,
|
|
19
|
+
allow_origins=["*"],
|
|
20
|
+
allow_credentials=True,
|
|
21
|
+
allow_methods=["*"],
|
|
22
|
+
allow_headers=["*"],
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
client = WalletKitPassClient()
|
|
26
|
+
|
|
27
|
+
@app.get("/")
|
|
28
|
+
async def root():
|
|
29
|
+
return {"status": "ok", "service": "pass-mcp-gateway"}
|
|
30
|
+
|
|
31
|
+
@app.post("/api/v1/mandates/issue")
|
|
32
|
+
async def issue_mandate(req: IssueMandateRequest):
|
|
33
|
+
try:
|
|
34
|
+
return await client.issue_mandate(
|
|
35
|
+
principal_id=req.principal_id,
|
|
36
|
+
agent_id=req.agent_id,
|
|
37
|
+
authorization_details=[d.model_dump(by_alias=True, exclude_none=True) for d in req.authorization_details],
|
|
38
|
+
agent_jkt=req.agent_jkt,
|
|
39
|
+
ttl_seconds=req.ttl_seconds or 3600,
|
|
40
|
+
)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
43
|
+
|
|
44
|
+
@app.post("/api/v1/mandates/enforce")
|
|
45
|
+
async def enforce_request(req: EnforcePassRequest):
|
|
46
|
+
try:
|
|
47
|
+
return await client.enforce_request(
|
|
48
|
+
mandate_token=req.mandate_token,
|
|
49
|
+
pass_class_id=req.pass_class_id,
|
|
50
|
+
resource=req.resource,
|
|
51
|
+
quantity=req.quantity or 1,
|
|
52
|
+
spend=req.spend or 0,
|
|
53
|
+
purpose=req.purpose,
|
|
54
|
+
dpop_proof=req.dpop_proof,
|
|
55
|
+
)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
58
|
+
|
|
59
|
+
@app.get("/api/v1/mandates/status")
|
|
60
|
+
async def check_status(token: str = Query(...)):
|
|
61
|
+
try:
|
|
62
|
+
return await client.check_mandate_status(token)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
raise HTTPException(status_code=401, detail=str(e))
|
|
65
|
+
|
|
66
|
+
def main():
|
|
67
|
+
port = int(os.getenv("PORT", 9000))
|
|
68
|
+
uvicorn.run("pass_mcp.api:app", host="0.0.0.0", port=port, reload=True)
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import Dict, Any, Optional
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
from pass_mcp.rate_limiter import check_and_increment_rate_limit, get_or_create_device_id
|
|
6
|
+
|
|
7
|
+
load_dotenv()
|
|
8
|
+
|
|
9
|
+
class WalletKitPassClient:
|
|
10
|
+
def __init__(self, base_url: Optional[str] = None, api_key: Optional[str] = None):
|
|
11
|
+
self.base_url = base_url or os.getenv("WALLETKIT_API_URL", "http://localhost:3000")
|
|
12
|
+
self.api_key = api_key or os.getenv("WALLETKIT_API_KEY")
|
|
13
|
+
self.device_id = get_or_create_device_id()
|
|
14
|
+
|
|
15
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
16
|
+
headers = {
|
|
17
|
+
"X-Pass-MCP-Device-Id": self.device_id,
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
}
|
|
20
|
+
if self.api_key:
|
|
21
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
22
|
+
headers["X-WalletKit-Api-Key"] = self.api_key
|
|
23
|
+
return headers
|
|
24
|
+
|
|
25
|
+
def _unwrap(self, response: httpx.Response) -> Dict[str, Any]:
|
|
26
|
+
data = response.json()
|
|
27
|
+
if isinstance(data, dict) and "data" in data and "success" in data:
|
|
28
|
+
return data["data"]
|
|
29
|
+
return data
|
|
30
|
+
|
|
31
|
+
async def issue_mandate(
|
|
32
|
+
self,
|
|
33
|
+
principal_id: str,
|
|
34
|
+
agent_id: str,
|
|
35
|
+
authorization_details: list,
|
|
36
|
+
agent_jkt: Optional[str] = None,
|
|
37
|
+
ttl_seconds: int = 3600,
|
|
38
|
+
) -> Dict[str, Any]:
|
|
39
|
+
allowed, msg = check_and_increment_rate_limit(has_api_key=bool(self.api_key))
|
|
40
|
+
if not allowed:
|
|
41
|
+
raise PermissionError(msg)
|
|
42
|
+
|
|
43
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=10.0) as client:
|
|
44
|
+
payload = {
|
|
45
|
+
"principalId": principal_id,
|
|
46
|
+
"agentId": agent_id,
|
|
47
|
+
"authorizationDetails": authorization_details,
|
|
48
|
+
"ttlSeconds": ttl_seconds,
|
|
49
|
+
}
|
|
50
|
+
if agent_jkt:
|
|
51
|
+
payload["agentJkt"] = agent_jkt
|
|
52
|
+
|
|
53
|
+
response = await client.post("/api/v1/mandates/issue", json=payload, headers=self._get_headers())
|
|
54
|
+
response.raise_for_status()
|
|
55
|
+
return self._unwrap(response)
|
|
56
|
+
|
|
57
|
+
async def enforce_request(
|
|
58
|
+
self,
|
|
59
|
+
mandate_token: str,
|
|
60
|
+
pass_class_id: str,
|
|
61
|
+
resource: Optional[str] = None,
|
|
62
|
+
quantity: int = 1,
|
|
63
|
+
spend: float = 0,
|
|
64
|
+
purpose: Optional[str] = None,
|
|
65
|
+
dpop_proof: Optional[str] = None,
|
|
66
|
+
) -> Dict[str, Any]:
|
|
67
|
+
allowed, msg = check_and_increment_rate_limit(has_api_key=bool(self.api_key))
|
|
68
|
+
if not allowed:
|
|
69
|
+
return {
|
|
70
|
+
"decision": "DENY",
|
|
71
|
+
"reason": msg,
|
|
72
|
+
"rate_limit_exceeded": True,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=10.0) as client:
|
|
76
|
+
payload = {
|
|
77
|
+
"mandateToken": mandate_token,
|
|
78
|
+
"passClassId": pass_class_id,
|
|
79
|
+
"quantity": quantity,
|
|
80
|
+
"spend": spend,
|
|
81
|
+
}
|
|
82
|
+
if resource:
|
|
83
|
+
payload["resource"] = resource
|
|
84
|
+
if purpose:
|
|
85
|
+
payload["purpose"] = purpose
|
|
86
|
+
if dpop_proof:
|
|
87
|
+
payload["dpopProof"] = dpop_proof
|
|
88
|
+
|
|
89
|
+
response = await client.post("/api/v1/mandates/enforce", json=payload, headers=self._get_headers())
|
|
90
|
+
response.raise_for_status()
|
|
91
|
+
return self._unwrap(response)
|
|
92
|
+
|
|
93
|
+
async def check_mandate_status(self, mandate_token: str) -> Dict[str, Any]:
|
|
94
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=10.0) as client:
|
|
95
|
+
response = await client.get("/api/v1/mandates/status", params={"token": mandate_token}, headers=self._get_headers())
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
return self._unwrap(response)
|
|
98
|
+
|
|
99
|
+
async def poll_escalation(self, auth_req_id: str) -> Dict[str, Any]:
|
|
100
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=10.0) as client:
|
|
101
|
+
response = await client.get(f"/api/v1/mandates/escalations/{auth_req_id}", headers=self._get_headers())
|
|
102
|
+
response.raise_for_status()
|
|
103
|
+
return self._unwrap(response)
|
|
104
|
+
|
|
105
|
+
async def revoke_mandate(self, jti: str, reason: Optional[str] = None) -> Dict[str, Any]:
|
|
106
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=10.0) as client:
|
|
107
|
+
payload = {"jti": jti}
|
|
108
|
+
if reason:
|
|
109
|
+
payload["reason"] = reason
|
|
110
|
+
response = await client.post("/api/v1/mandates/revoke", json=payload, headers=self._get_headers())
|
|
111
|
+
response.raise_for_status()
|
|
112
|
+
return self._unwrap(response)
|
|
113
|
+
|
|
114
|
+
async def get_holder_passes(self, external_user_id: Optional[str] = None, mandate_token: Optional[str] = None) -> Dict[str, Any]:
|
|
115
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=10.0) as client:
|
|
116
|
+
params = {}
|
|
117
|
+
if external_user_id:
|
|
118
|
+
params["externalUserId"] = external_user_id
|
|
119
|
+
if mandate_token:
|
|
120
|
+
params["token"] = mandate_token
|
|
121
|
+
response = await client.get("/api/v1/mandates/holder-passes", params=params, headers=self._get_headers())
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
return self._unwrap(response)
|
|
124
|
+
|
|
125
|
+
async def lookup_pass(self, pass_id: str) -> Dict[str, Any]:
|
|
126
|
+
async with httpx.AsyncClient(base_url=self.base_url, timeout=10.0) as client:
|
|
127
|
+
response = await client.get("/api/v1/mandates/pass-lookup", params={"passId": pass_id}, headers=self._get_headers())
|
|
128
|
+
response.raise_for_status()
|
|
129
|
+
return self._unwrap(response)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Optional, List, Dict, Any
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
from pass_mcp.client import WalletKitPassClient
|
|
7
|
+
|
|
8
|
+
mcp = FastMCP("pass-mcp")
|
|
9
|
+
client = WalletKitPassClient()
|
|
10
|
+
|
|
11
|
+
def format_pass_result(result: Any) -> str:
|
|
12
|
+
"""Formats pass responses into clean JSON with visual pass card preview image markdown."""
|
|
13
|
+
try:
|
|
14
|
+
json_str = json.dumps(result, indent=2)
|
|
15
|
+
if isinstance(result, dict):
|
|
16
|
+
pass_obj = result.get("pass") or (result if "urls" in result else None)
|
|
17
|
+
if isinstance(pass_obj, dict):
|
|
18
|
+
card_url = pass_obj.get("urls", {}).get("previewCardUrl")
|
|
19
|
+
if card_url:
|
|
20
|
+
return f"\n\n" + json_str
|
|
21
|
+
return json_str
|
|
22
|
+
except Exception:
|
|
23
|
+
return json.dumps(result, indent=2)
|
|
24
|
+
|
|
25
|
+
@mcp.tool()
|
|
26
|
+
async def request_pass(
|
|
27
|
+
mandate_token: str,
|
|
28
|
+
pass_class: str,
|
|
29
|
+
resource: Optional[str] = None,
|
|
30
|
+
quantity: int = 1,
|
|
31
|
+
spend: float = 0,
|
|
32
|
+
purpose: Optional[str] = None,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Request a digital wallet pass (Apple Wallet .pkpass / Google Wallet pass) under a signed agent delegation token.
|
|
36
|
+
Enforces a default free limit of 10 passes per day per installer.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
result = await client.enforce_request(
|
|
40
|
+
mandate_token=mandate_token,
|
|
41
|
+
pass_class_id=pass_class,
|
|
42
|
+
resource=resource,
|
|
43
|
+
quantity=quantity,
|
|
44
|
+
spend=spend,
|
|
45
|
+
purpose=purpose,
|
|
46
|
+
)
|
|
47
|
+
return format_pass_result(result)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
50
|
+
|
|
51
|
+
@mcp.tool()
|
|
52
|
+
async def check_mandate_status(mandate_token: str) -> str:
|
|
53
|
+
"""Check live status and remaining lifetime of a delegation token."""
|
|
54
|
+
try:
|
|
55
|
+
result = await client.check_mandate_status(mandate_token=mandate_token)
|
|
56
|
+
return json.dumps(result, indent=2)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
async def poll_escalation(auth_req_id: str) -> str:
|
|
62
|
+
"""Poll status of a pending CIBA human-in-the-loop escalation request."""
|
|
63
|
+
try:
|
|
64
|
+
result = await client.poll_escalation(auth_req_id=auth_req_id)
|
|
65
|
+
return json.dumps(result, indent=2)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
68
|
+
|
|
69
|
+
@mcp.tool()
|
|
70
|
+
async def issue_mandate(
|
|
71
|
+
principal_id: str,
|
|
72
|
+
agent_id: str,
|
|
73
|
+
authorization_details: List[Dict[str, Any]],
|
|
74
|
+
ttl_seconds: int = 3600,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Issue a signed RFC 8693 delegation mandate token for an AI agent (Principal / Admin tool).
|
|
78
|
+
authorization_details array format: [{'type': 'event_ticket', 'pass_class': 'event_ticket', 'max_quantity': 2}]
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
normalized_details = []
|
|
82
|
+
sys.stderr.write(f"[pass-mcp] authorization_details ==> {authorization_details}\n")
|
|
83
|
+
for detail in authorization_details:
|
|
84
|
+
if isinstance(detail, dict):
|
|
85
|
+
d = dict(detail)
|
|
86
|
+
if "pass_class" not in d and "type" in d:
|
|
87
|
+
d["pass_class"] = d["type"]
|
|
88
|
+
if "max_quantity" not in d and "quantity" in d:
|
|
89
|
+
d["max_quantity"] = d["quantity"]
|
|
90
|
+
normalized_details.append(d)
|
|
91
|
+
else:
|
|
92
|
+
normalized_details.append(detail)
|
|
93
|
+
|
|
94
|
+
result = await client.issue_mandate(
|
|
95
|
+
principal_id=principal_id,
|
|
96
|
+
agent_id=agent_id,
|
|
97
|
+
authorization_details=normalized_details,
|
|
98
|
+
ttl_seconds=ttl_seconds,
|
|
99
|
+
)
|
|
100
|
+
return json.dumps(result, indent=2)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
103
|
+
|
|
104
|
+
@mcp.tool()
|
|
105
|
+
async def get_holder_passes(
|
|
106
|
+
external_user_id: Optional[str] = None,
|
|
107
|
+
mandate_token: Optional[str] = None,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Get all active Apple & Google Wallet passes held by a human principal / user."""
|
|
110
|
+
try:
|
|
111
|
+
result = await client.get_holder_passes(
|
|
112
|
+
external_user_id=external_user_id,
|
|
113
|
+
mandate_token=mandate_token,
|
|
114
|
+
)
|
|
115
|
+
return format_pass_result(result)
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
118
|
+
|
|
119
|
+
@mcp.tool()
|
|
120
|
+
async def lookup_pass(pass_id: str) -> str:
|
|
121
|
+
"""Lookup full details of a specific pass by passId or serialNumber."""
|
|
122
|
+
try:
|
|
123
|
+
result = await client.lookup_pass(pass_id=pass_id)
|
|
124
|
+
return format_pass_result(result)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
127
|
+
|
|
128
|
+
def main():
|
|
129
|
+
"""Main entrypoint running Pass-MCP server over Stdio transport."""
|
|
130
|
+
mcp.run(transport="stdio")
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Tuple
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".pass_mcp"
|
|
9
|
+
DEVICE_ID_FILE = CONFIG_DIR / "device_id"
|
|
10
|
+
USAGE_FILE = CONFIG_DIR / "usage.json"
|
|
11
|
+
DAILY_FREE_PASS_LIMIT = 100
|
|
12
|
+
|
|
13
|
+
def get_or_create_device_id() -> str:
|
|
14
|
+
"""Gets or generates a persistent device/installer UUID."""
|
|
15
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
if DEVICE_ID_FILE.exists():
|
|
17
|
+
device_id = DEVICE_ID_FILE.read_text().strip()
|
|
18
|
+
if device_id:
|
|
19
|
+
return device_id
|
|
20
|
+
|
|
21
|
+
new_id = f"dev_{uuid.uuid4().hex[:16]}"
|
|
22
|
+
DEVICE_ID_FILE.write_text(new_id)
|
|
23
|
+
return new_id
|
|
24
|
+
|
|
25
|
+
def check_and_increment_rate_limit(has_api_key: bool = False) -> Tuple[bool, str]:
|
|
26
|
+
"""
|
|
27
|
+
Checks if the current device/installer is allowed to issue a pass.
|
|
28
|
+
If has_api_key is True, bypasses local rate limit.
|
|
29
|
+
Otherwise, enforces hardcoded 10 passes per day.
|
|
30
|
+
"""
|
|
31
|
+
if has_api_key:
|
|
32
|
+
return True, "API Key authenticated: Unlimited quota."
|
|
33
|
+
|
|
34
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
today = datetime.date.today().isoformat()
|
|
36
|
+
device_id = get_or_create_device_id()
|
|
37
|
+
|
|
38
|
+
usage_data = {"date": today, "count": 0, "device_id": device_id}
|
|
39
|
+
|
|
40
|
+
if USAGE_FILE.exists():
|
|
41
|
+
try:
|
|
42
|
+
stored = json.loads(USAGE_FILE.read_text())
|
|
43
|
+
if stored.get("date") == today:
|
|
44
|
+
usage_data["count"] = stored.get("count", 0)
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
if usage_data["count"] >= DAILY_FREE_PASS_LIMIT:
|
|
49
|
+
return False, (
|
|
50
|
+
f"Daily free limit reached ({DAILY_FREE_PASS_LIMIT}/{DAILY_FREE_PASS_LIMIT} passes issued today for device {device_id}). "
|
|
51
|
+
f"Set WALLETKIT_API_KEY environment variable for unlimited pass issuance."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Increment count
|
|
55
|
+
usage_data["count"] += 1
|
|
56
|
+
USAGE_FILE.write_text(json.dumps(usage_data, indent=2))
|
|
57
|
+
|
|
58
|
+
remaining = DAILY_FREE_PASS_LIMIT - usage_data["count"]
|
|
59
|
+
return True, f"Free daily pass issued ({usage_data['count']}/{DAILY_FREE_PASS_LIMIT} used today, {remaining} remaining)."
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from typing import Optional, List, Dict, Any
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
|
|
4
|
+
class ApprovalThresholdSchema(BaseModel):
|
|
5
|
+
quantity: Optional[int] = None
|
|
6
|
+
spend: Optional[float] = None
|
|
7
|
+
|
|
8
|
+
class AuthorizationDetailSchema(BaseModel):
|
|
9
|
+
type: str
|
|
10
|
+
pass_class: str
|
|
11
|
+
resource: Optional[str] = None
|
|
12
|
+
max_quantity: Optional[int] = None
|
|
13
|
+
max_spend: Optional[float] = None
|
|
14
|
+
currency: Optional[str] = None
|
|
15
|
+
purpose: Optional[str] = None
|
|
16
|
+
approval_threshold: Optional[ApprovalThresholdSchema] = None
|
|
17
|
+
|
|
18
|
+
class IssueMandateRequest(BaseModel):
|
|
19
|
+
principal_id: str = Field(..., alias="principalId")
|
|
20
|
+
agent_id: str = Field(..., alias="agentId")
|
|
21
|
+
agent_jkt: Optional[str] = Field(None, alias="agentJkt")
|
|
22
|
+
authorization_details: List[AuthorizationDetailSchema] = Field(..., alias="authorizationDetails")
|
|
23
|
+
ttl_seconds: Optional[int] = Field(3600, alias="ttlSeconds")
|
|
24
|
+
|
|
25
|
+
class Config:
|
|
26
|
+
populate_by_name = True
|
|
27
|
+
|
|
28
|
+
class EnforcePassRequest(BaseModel):
|
|
29
|
+
mandate_token: str = Field(..., alias="mandateToken")
|
|
30
|
+
pass_class_id: str = Field(..., alias="passClassId")
|
|
31
|
+
resource: Optional[str] = None
|
|
32
|
+
quantity: Optional[int] = 1
|
|
33
|
+
spend: Optional[float] = 0
|
|
34
|
+
purpose: Optional[str] = None
|
|
35
|
+
dpop_proof: Optional[str] = Field(None, alias="dpopProof")
|
|
36
|
+
|
|
37
|
+
class Config:
|
|
38
|
+
populate_by_name = True
|
|
39
|
+
|
|
40
|
+
class RevokeMandateRequest(BaseModel):
|
|
41
|
+
jti: str
|
|
42
|
+
reason: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
class EnforcementResult(BaseModel):
|
|
45
|
+
decision: str # ALLOW | DENY | ESCALATE | ERROR
|
|
46
|
+
reason: str
|
|
47
|
+
mandate_jti: Optional[str] = Field(None, alias="mandateJti")
|
|
48
|
+
principal_id: Optional[str] = Field(None, alias="principalId")
|
|
49
|
+
agent_id: Optional[str] = Field(None, alias="agentId")
|
|
50
|
+
auth_req_id: Optional[str] = Field(None, alias="authReqId")
|
|
51
|
+
pass_data: Optional[Dict[str, Any]] = Field(None, alias="pass")
|
|
52
|
+
audit_hash: Optional[str] = Field(None, alias="auditHash")
|
|
53
|
+
is_replay: Optional[bool] = Field(False, alias="isReplay")
|
|
54
|
+
|
|
55
|
+
class Config:
|
|
56
|
+
populate_by_name = True
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pass-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Model Context Protocol (MCP) Server & Python Client for Apple & Google Wallet Pass Delegation
|
|
5
|
+
Author: Emmanuel Biolatiri
|
|
6
|
+
Requires-Dist: fastapi>=0.110.0
|
|
7
|
+
Requires-Dist: uvicorn[standard]>=0.28.0
|
|
8
|
+
Requires-Dist: mcp>=1.0.0
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: pydantic>=2.6.0
|
|
11
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
pass_mcp/__init__.py
|
|
4
|
+
pass_mcp/api.py
|
|
5
|
+
pass_mcp/client.py
|
|
6
|
+
pass_mcp/mcp_server.py
|
|
7
|
+
pass_mcp/rate_limiter.py
|
|
8
|
+
pass_mcp/schemas.py
|
|
9
|
+
pass_mcp.egg-info/PKG-INFO
|
|
10
|
+
pass_mcp.egg-info/SOURCES.txt
|
|
11
|
+
pass_mcp.egg-info/dependency_links.txt
|
|
12
|
+
pass_mcp.egg-info/entry_points.txt
|
|
13
|
+
pass_mcp.egg-info/requires.txt
|
|
14
|
+
pass_mcp.egg-info/top_level.txt
|
|
15
|
+
tests/test_client.py
|
|
16
|
+
tests/test_conformance.py
|
|
17
|
+
tests/test_mcp_live_stdio.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pass_mcp
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pass-mcp"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Model Context Protocol (MCP) Server & Python Client for Apple & Google Wallet Pass Delegation"
|
|
9
|
+
authors = [{ name = "Emmanuel Biolatiri" }]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi>=0.110.0",
|
|
12
|
+
"uvicorn[standard]>=0.28.0",
|
|
13
|
+
"mcp>=1.0.0",
|
|
14
|
+
"httpx>=0.27.0",
|
|
15
|
+
"pydantic>=2.6.0",
|
|
16
|
+
"python-dotenv>=1.0.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["."]
|
|
21
|
+
include = ["pass_mcp*"]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
pass-mcp = "pass_mcp.mcp_server:main"
|
|
25
|
+
pass-mcp-api = "pass_mcp.api:main"
|
pass_mcp-1.0.0/setup.cfg
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pass_mcp.client import WalletKitPassClient
|
|
3
|
+
from pass_mcp.rate_limiter import check_and_increment_rate_limit
|
|
4
|
+
|
|
5
|
+
def test_client_instantiation():
|
|
6
|
+
client = WalletKitPassClient(base_url="http://localhost:3000")
|
|
7
|
+
assert client is not None
|
|
8
|
+
assert client.base_url == "http://localhost:3000"
|
|
9
|
+
assert hasattr(client, "issue_mandate")
|
|
10
|
+
assert hasattr(client, "enforce_request")
|
|
11
|
+
assert hasattr(client, "check_mandate_status")
|
|
12
|
+
|
|
13
|
+
def test_rate_limiter_local():
|
|
14
|
+
allowed, msg = check_and_increment_rate_limit(has_api_key=False)
|
|
15
|
+
assert allowed is True
|
|
16
|
+
assert "Free daily pass" in msg
|
|
17
|
+
|
|
18
|
+
def test_rate_limiter_api_key_bypass():
|
|
19
|
+
allowed, msg = check_and_increment_rate_limit(has_api_key=True)
|
|
20
|
+
assert allowed is True
|
|
21
|
+
assert "Unlimited" in msg
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pass-MCP Conformance Benchmark Suite
|
|
3
|
+
Verifies over-the-wire implementation of RFC 8693 token exchange, draft-klrc-aiagent-auth,
|
|
4
|
+
and all 15 core security & delegation invariants against running walletKit backend.
|
|
5
|
+
"""
|
|
6
|
+
import pytest
|
|
7
|
+
import httpx
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
class TestPassMCPConformanceSuite:
|
|
11
|
+
|
|
12
|
+
@pytest.mark.asyncio
|
|
13
|
+
async def test_case_01_mandate_adherence(self, pass_client):
|
|
14
|
+
"""Case 1: Normal delegated purchase within scope (0 out-of-scope issuances)."""
|
|
15
|
+
try:
|
|
16
|
+
issue_res = await pass_client.issue_mandate(
|
|
17
|
+
principal_id="usr_human_conf_01",
|
|
18
|
+
agent_id="agent_ai_conf_01",
|
|
19
|
+
authorization_details=[{
|
|
20
|
+
"type": "event_ticket",
|
|
21
|
+
"pass_class": "event_ticket",
|
|
22
|
+
"max_quantity": 2
|
|
23
|
+
}]
|
|
24
|
+
)
|
|
25
|
+
token = issue_res["token"]
|
|
26
|
+
|
|
27
|
+
enforce_res = await pass_client.enforce_request(
|
|
28
|
+
mandate_token=token,
|
|
29
|
+
pass_class_id="event_ticket",
|
|
30
|
+
quantity=1
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
assert enforce_res["decision"] == "ALLOW"
|
|
34
|
+
assert enforce_res["pass"]["externalUserId"] == "usr_human_conf_01"
|
|
35
|
+
except httpx.HTTPError:
|
|
36
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
37
|
+
|
|
38
|
+
@pytest.mark.asyncio
|
|
39
|
+
async def test_case_02_wrapped_scalper(self, pass_client):
|
|
40
|
+
"""Case 2: Wrapped scalper (bot presenting well-formed but undelegated identity)."""
|
|
41
|
+
try:
|
|
42
|
+
invalid_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IndhbGxldGtpdC1tYW5kYXRlK2p3dCJ9.eyJzdWIiOiJ1c3JfaHVtYW4iLCJtYW5kYXRlX2p0aSI6Im1uZF9mYWtlIn0.invalid"
|
|
43
|
+
enforce_res = await pass_client.enforce_request(
|
|
44
|
+
mandate_token=invalid_token,
|
|
45
|
+
pass_class_id="event_ticket"
|
|
46
|
+
)
|
|
47
|
+
assert enforce_res["decision"] in ["DENY", "ERROR"]
|
|
48
|
+
except httpx.HTTPError:
|
|
49
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_case_03_injection_escalation(self, pass_client):
|
|
53
|
+
"""Case 3: Attempting body instruction scope escalation beyond token claims."""
|
|
54
|
+
try:
|
|
55
|
+
issue_res = await pass_client.issue_mandate(
|
|
56
|
+
principal_id="usr_human_conf_03",
|
|
57
|
+
agent_id="agent_ai_conf_03",
|
|
58
|
+
authorization_details=[{
|
|
59
|
+
"type": "event_ticket",
|
|
60
|
+
"pass_class": "event_ticket",
|
|
61
|
+
"max_quantity": 1
|
|
62
|
+
}]
|
|
63
|
+
)
|
|
64
|
+
token = issue_res["token"]
|
|
65
|
+
|
|
66
|
+
enforce_res = await pass_client.enforce_request(
|
|
67
|
+
mandate_token=token,
|
|
68
|
+
pass_class_id="event_ticket",
|
|
69
|
+
quantity=5 # Exceeds ceiling of 1
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
assert enforce_res["decision"] == "DENY"
|
|
73
|
+
except httpx.HTTPError:
|
|
74
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_case_04_retry_idempotency(self, pass_client):
|
|
78
|
+
"""Case 4: Replay duplicate request under context reset returns stored outcome."""
|
|
79
|
+
try:
|
|
80
|
+
issue_res = await pass_client.issue_mandate(
|
|
81
|
+
principal_id="usr_human_conf_04",
|
|
82
|
+
agent_id="agent_ai_conf_04",
|
|
83
|
+
authorization_details=[{
|
|
84
|
+
"type": "event_ticket",
|
|
85
|
+
"pass_class": "event_ticket",
|
|
86
|
+
"max_quantity": 1
|
|
87
|
+
}]
|
|
88
|
+
)
|
|
89
|
+
token = issue_res["token"]
|
|
90
|
+
|
|
91
|
+
res1 = await pass_client.enforce_request(
|
|
92
|
+
mandate_token=token,
|
|
93
|
+
pass_class_id="event_ticket",
|
|
94
|
+
quantity=1
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
res2 = await pass_client.enforce_request(
|
|
98
|
+
mandate_token=token,
|
|
99
|
+
pass_class_id="event_ticket",
|
|
100
|
+
quantity=1
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert res1["decision"] == res2["decision"]
|
|
104
|
+
assert res2.get("isReplay") is True
|
|
105
|
+
except httpx.HTTPError:
|
|
106
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_case_05_replayed_denial(self, pass_client):
|
|
110
|
+
"""Case 5: Replayed DENY returns stored DENY without re-evaluation."""
|
|
111
|
+
try:
|
|
112
|
+
issue_res = await pass_client.issue_mandate(
|
|
113
|
+
principal_id="usr_human_conf_05",
|
|
114
|
+
agent_id="agent_ai_conf_05",
|
|
115
|
+
authorization_details=[{
|
|
116
|
+
"type": "event_ticket",
|
|
117
|
+
"pass_class": "event_ticket",
|
|
118
|
+
"max_quantity": 1
|
|
119
|
+
}]
|
|
120
|
+
)
|
|
121
|
+
token = issue_res["token"]
|
|
122
|
+
|
|
123
|
+
# Trigger DENY by exceeding quantity limit
|
|
124
|
+
res1 = await pass_client.enforce_request(
|
|
125
|
+
mandate_token=token,
|
|
126
|
+
pass_class_id="event_ticket",
|
|
127
|
+
quantity=10
|
|
128
|
+
)
|
|
129
|
+
assert res1["decision"] == "DENY"
|
|
130
|
+
|
|
131
|
+
# Replay denial
|
|
132
|
+
res2 = await pass_client.enforce_request(
|
|
133
|
+
mandate_token=token,
|
|
134
|
+
pass_class_id="event_ticket",
|
|
135
|
+
quantity=10
|
|
136
|
+
)
|
|
137
|
+
assert res2["decision"] == "DENY"
|
|
138
|
+
assert res2.get("isReplay") is True
|
|
139
|
+
except httpx.HTTPError:
|
|
140
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_case_06_escalation_boundary(self, pass_client):
|
|
144
|
+
"""Case 6: Requests just-above threshold trigger ESCALATE, just-below trigger ALLOW."""
|
|
145
|
+
try:
|
|
146
|
+
issue_res = await pass_client.issue_mandate(
|
|
147
|
+
principal_id="usr_human_conf_06",
|
|
148
|
+
agent_id="agent_ai_conf_06",
|
|
149
|
+
authorization_details=[{
|
|
150
|
+
"type": "event_ticket",
|
|
151
|
+
"pass_class": "event_ticket",
|
|
152
|
+
"max_quantity": 10,
|
|
153
|
+
"approval_threshold": {"quantity": 1}
|
|
154
|
+
}]
|
|
155
|
+
)
|
|
156
|
+
token = issue_res["token"]
|
|
157
|
+
|
|
158
|
+
# Just-below/equal threshold -> ALLOW
|
|
159
|
+
res_below = await pass_client.enforce_request(
|
|
160
|
+
mandate_token=token,
|
|
161
|
+
pass_class_id="event_ticket",
|
|
162
|
+
quantity=1
|
|
163
|
+
)
|
|
164
|
+
assert res_below["decision"] == "ALLOW"
|
|
165
|
+
|
|
166
|
+
# Just-above threshold -> ESCALATE
|
|
167
|
+
res_above = await pass_client.enforce_request(
|
|
168
|
+
mandate_token=token,
|
|
169
|
+
pass_class_id="event_ticket",
|
|
170
|
+
quantity=2
|
|
171
|
+
)
|
|
172
|
+
assert res_above["decision"] == "ESCALATE"
|
|
173
|
+
assert "authReqId" in res_above or "auth_req_id" in res_above
|
|
174
|
+
except httpx.HTTPError:
|
|
175
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
176
|
+
|
|
177
|
+
@pytest.mark.asyncio
|
|
178
|
+
async def test_case_07_escalation_timeout(self, pass_client):
|
|
179
|
+
"""Case 7: Unresponded escalation request times out and fails closed (DENY)."""
|
|
180
|
+
try:
|
|
181
|
+
issue_res = await pass_client.issue_mandate(
|
|
182
|
+
principal_id="usr_human_conf_07",
|
|
183
|
+
agent_id="agent_ai_conf_07",
|
|
184
|
+
authorization_details=[{
|
|
185
|
+
"type": "event_ticket",
|
|
186
|
+
"pass_class": "event_ticket",
|
|
187
|
+
"max_quantity": 10,
|
|
188
|
+
"approval_threshold": {"quantity": 1}
|
|
189
|
+
}]
|
|
190
|
+
)
|
|
191
|
+
token = issue_res["token"]
|
|
192
|
+
|
|
193
|
+
res_above = await pass_client.enforce_request(
|
|
194
|
+
mandate_token=token,
|
|
195
|
+
pass_class_id="event_ticket",
|
|
196
|
+
quantity=3
|
|
197
|
+
)
|
|
198
|
+
auth_req_id = res_above.get("authReqId") or res_above.get("auth_req_id")
|
|
199
|
+
assert auth_req_id is not None
|
|
200
|
+
|
|
201
|
+
# Poll escalation status
|
|
202
|
+
escalation = await pass_client.poll_escalation(auth_req_id)
|
|
203
|
+
assert escalation["status"] in ["PENDING", "EXPIRED", "DENIED"]
|
|
204
|
+
except httpx.HTTPError:
|
|
205
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
206
|
+
|
|
207
|
+
@pytest.mark.asyncio
|
|
208
|
+
async def test_case_09_revocation_latency(self, pass_client):
|
|
209
|
+
"""Case 9: Revocation latency (revoke -> first denied attempt)."""
|
|
210
|
+
try:
|
|
211
|
+
issue_res = await pass_client.issue_mandate(
|
|
212
|
+
principal_id="usr_human_conf_09",
|
|
213
|
+
agent_id="agent_ai_conf_09",
|
|
214
|
+
authorization_details=[{
|
|
215
|
+
"type": "event_ticket",
|
|
216
|
+
"pass_class": "event_ticket",
|
|
217
|
+
"max_quantity": 5
|
|
218
|
+
}]
|
|
219
|
+
)
|
|
220
|
+
token = issue_res["token"]
|
|
221
|
+
jti = issue_res["mandate"]["jti"]
|
|
222
|
+
|
|
223
|
+
# Revoke mandate
|
|
224
|
+
await pass_client.revoke_mandate(jti=jti, reason="Revocation testing")
|
|
225
|
+
|
|
226
|
+
# First attempt post-revocation must fail immediately
|
|
227
|
+
enforce_res = await pass_client.enforce_request(
|
|
228
|
+
mandate_token=token,
|
|
229
|
+
pass_class_id="event_ticket",
|
|
230
|
+
quantity=1
|
|
231
|
+
)
|
|
232
|
+
assert enforce_res["decision"] in ["DENY", "ERROR"]
|
|
233
|
+
except httpx.HTTPError:
|
|
234
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
235
|
+
|
|
236
|
+
@pytest.mark.asyncio
|
|
237
|
+
async def test_case_12_principal_binding(self, pass_client):
|
|
238
|
+
"""Case 12: Issued pass MUST bind to human principal, never to agent."""
|
|
239
|
+
try:
|
|
240
|
+
issue_res = await pass_client.issue_mandate(
|
|
241
|
+
principal_id="usr_human_conf_12",
|
|
242
|
+
agent_id="agent_ai_conf_12",
|
|
243
|
+
authorization_details=[{
|
|
244
|
+
"type": "event_ticket",
|
|
245
|
+
"pass_class": "event_ticket"
|
|
246
|
+
}]
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
result = await pass_client.enforce_request(
|
|
250
|
+
mandate_token=issue_res["token"],
|
|
251
|
+
pass_class_id="event_ticket"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
assert result["decision"] == "ALLOW"
|
|
255
|
+
assert result["pass"]["externalUserId"] == "usr_human_conf_12"
|
|
256
|
+
assert result["pass"]["externalUserId"] != "agent_ai_conf_12"
|
|
257
|
+
except httpx.HTTPError:
|
|
258
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
259
|
+
|
|
260
|
+
@pytest.mark.asyncio
|
|
261
|
+
async def test_case_15_audit_completeness(self, pass_client):
|
|
262
|
+
"""Case 15: Every decision is recorded in audit ledger and chain integrity holds."""
|
|
263
|
+
try:
|
|
264
|
+
issue_res = await pass_client.issue_mandate(
|
|
265
|
+
principal_id="usr_human_conf_15",
|
|
266
|
+
agent_id="agent_ai_conf_15",
|
|
267
|
+
authorization_details=[{
|
|
268
|
+
"type": "event_ticket",
|
|
269
|
+
"pass_class": "event_ticket"
|
|
270
|
+
}]
|
|
271
|
+
)
|
|
272
|
+
jti = issue_res["mandate"]["jti"]
|
|
273
|
+
|
|
274
|
+
# Perform action
|
|
275
|
+
await pass_client.enforce_request(
|
|
276
|
+
mandate_token=issue_res["token"],
|
|
277
|
+
pass_class_id="event_ticket"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Reconstruct audit ledger via API
|
|
281
|
+
async with httpx.AsyncClient(base_url=pass_client.base_url) as client:
|
|
282
|
+
res = await client.get(f"/api/v1/mandates/audit/{jti}")
|
|
283
|
+
assert res.status_code == 200
|
|
284
|
+
audit_data = res.json().get("data", res.json())
|
|
285
|
+
assert audit_data["valid"] is True
|
|
286
|
+
assert len(audit_data["history"]) >= 1
|
|
287
|
+
except httpx.HTTPError:
|
|
288
|
+
pytest.skip("walletKit backend server not currently running at WALLETKIT_API_URL")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test script simulating Claude Desktop / MCP client stdio connection to pass_mcp.mcp_server.
|
|
3
|
+
"""
|
|
4
|
+
import sys
|
|
5
|
+
import subprocess
|
|
6
|
+
import json
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
@pytest.mark.asyncio
|
|
10
|
+
async def test_mcp_stdio_server_communication():
|
|
11
|
+
"""Launches pass-mcp via subprocess and verifies JSON-RPC initialize & list_tools responses."""
|
|
12
|
+
proc = subprocess.Popen(
|
|
13
|
+
[sys.executable, "-m", "pass_mcp.mcp_server"],
|
|
14
|
+
stdin=subprocess.PIPE,
|
|
15
|
+
stdout=subprocess.PIPE,
|
|
16
|
+
stderr=subprocess.PIPE,
|
|
17
|
+
text=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# 1. Send MCP Initialize request
|
|
21
|
+
init_request = {
|
|
22
|
+
"jsonrpc": "2.0",
|
|
23
|
+
"id": 1,
|
|
24
|
+
"method": "initialize",
|
|
25
|
+
"params": {
|
|
26
|
+
"protocolVersion": "2024-11-05",
|
|
27
|
+
"capabilities": {},
|
|
28
|
+
"clientInfo": {"name": "test-client", "version": "1.0.0"},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
proc.stdin.write(json.dumps(init_request) + "\n")
|
|
33
|
+
proc.stdin.flush()
|
|
34
|
+
|
|
35
|
+
line = proc.stdout.readline()
|
|
36
|
+
assert line, "MCP server closed output stream unexpectedly"
|
|
37
|
+
res = json.loads(line)
|
|
38
|
+
assert res.get("id") == 1
|
|
39
|
+
assert "result" in res
|
|
40
|
+
assert res["result"]["serverInfo"]["name"] == "pass-mcp"
|
|
41
|
+
|
|
42
|
+
# 2. Send tools/list request
|
|
43
|
+
list_tools_request = {
|
|
44
|
+
"jsonrpc": "2.0",
|
|
45
|
+
"id": 2,
|
|
46
|
+
"method": "tools/list",
|
|
47
|
+
"params": {},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
proc.stdin.write(json.dumps(list_tools_request) + "\n")
|
|
51
|
+
proc.stdin.flush()
|
|
52
|
+
|
|
53
|
+
line = proc.stdout.readline()
|
|
54
|
+
res = json.loads(line)
|
|
55
|
+
assert res.get("id") == 2
|
|
56
|
+
tools = res["result"]["tools"]
|
|
57
|
+
tool_names = [t["name"] for t in tools]
|
|
58
|
+
|
|
59
|
+
assert "request_pass" in tool_names
|
|
60
|
+
assert "check_mandate_status" in tool_names
|
|
61
|
+
assert "poll_escalation" in tool_names
|
|
62
|
+
assert "issue_mandate" in tool_names
|
|
63
|
+
assert "get_holder_passes" in tool_names
|
|
64
|
+
assert "lookup_pass" in tool_names
|
|
65
|
+
|
|
66
|
+
# Terminate server process cleanly
|
|
67
|
+
proc.terminate()
|
|
68
|
+
proc.wait(timeout=2)
|