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.
@@ -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,112 @@
1
+ # Pass-MCP (`pass-mcp`)
2
+
3
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
4
+ [![MCP Spec](https://img.shields.io/badge/MCP-1.0.0-green.svg)](https://modelcontextprotocol.io/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ """
2
+ walletKit Mandate - Pure Python MCP Server & API Client
3
+ """
4
+
5
+ __version__ = "1.0.0"
@@ -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"![Pass Visual Card]({card_url})\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,3 @@
1
+ [console_scripts]
2
+ pass-mcp = pass_mcp.mcp_server:main
3
+ pass-mcp-api = pass_mcp.api:main
@@ -0,0 +1,6 @@
1
+ fastapi>=0.110.0
2
+ uvicorn[standard]>=0.28.0
3
+ mcp>=1.0.0
4
+ httpx>=0.27.0
5
+ pydantic>=2.6.0
6
+ python-dotenv>=1.0.0
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)