Spaces:
Running
Running
Merge branch 'main' into feature/provenance-ledger
Browse files- examples/contract_example.py +401 -0
- src/fastmcp/contracts/__init__.py +7 -0
- src/fastmcp/contracts/contract.py +306 -0
- src/fastmcp/contracts/crypto.py +230 -0
- src/fastmcp/contracts/engine.py +357 -0
- src/fastmcp/contracts/registry.py +391 -0
- src/fastmcp/server/contract_routes.py +425 -0
- src/fastmcp/server/server.py +34 -0
- tests/contracts/__init__.py +1 -0
- tests/contracts/test_contract_engine.py +408 -0
- tests/contracts/test_contract_http.py +298 -0
examples/contract_example.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastMCP Contract Management Example
|
| 3 |
+
|
| 4 |
+
This example demonstrates the inter-agent contract management system with:
|
| 5 |
+
- Contract lifecycle management (draft, propose, sign, revoke)
|
| 6 |
+
- Ed25519 cryptographic signatures for non-repudiation
|
| 7 |
+
- HIPAA compliance support
|
| 8 |
+
- SQLite persistence
|
| 9 |
+
- HTTP API endpoints
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import json
|
| 14 |
+
from datetime import datetime, timedelta
|
| 15 |
+
|
| 16 |
+
from fastmcp import FastMCP
|
| 17 |
+
from fastmcp.contracts import ContractEngine, ContractState
|
| 18 |
+
from fastmcp.contracts.contract import (
|
| 19 |
+
Clause, ContractCreateRequest, ContractProposeRequest,
|
| 20 |
+
ContractSignRequest, ContractRevokeRequest
|
| 21 |
+
)
|
| 22 |
+
from fastmcp.contracts.crypto import generate_key_pair, Ed25519Signer
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def create_contract_example():
|
| 26 |
+
"""Create a FastMCP server with contract engine enabled."""
|
| 27 |
+
|
| 28 |
+
# Create server
|
| 29 |
+
server = FastMCP("Contract Management Server")
|
| 30 |
+
|
| 31 |
+
# Enable contract engine
|
| 32 |
+
contract_engine = server.enable_contract_engine()
|
| 33 |
+
|
| 34 |
+
# Add a simple tool that demonstrates contract operations
|
| 35 |
+
@server.tool
|
| 36 |
+
async def create_sample_contract(title: str, description: str) -> dict:
|
| 37 |
+
"""Create a sample contract for demonstration."""
|
| 38 |
+
|
| 39 |
+
# Create sample clauses
|
| 40 |
+
clauses = [
|
| 41 |
+
Clause(
|
| 42 |
+
title="Data Protection",
|
| 43 |
+
content="All personal data must be protected according to HIPAA regulations.",
|
| 44 |
+
type="hipaa"
|
| 45 |
+
),
|
| 46 |
+
Clause(
|
| 47 |
+
title="Access Control",
|
| 48 |
+
content="Only authorized personnel may access patient data.",
|
| 49 |
+
type="security"
|
| 50 |
+
),
|
| 51 |
+
Clause(
|
| 52 |
+
title="Audit Trail",
|
| 53 |
+
content="All data access must be logged for audit purposes.",
|
| 54 |
+
type="compliance"
|
| 55 |
+
)
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
# Create sample parties
|
| 59 |
+
parties = [
|
| 60 |
+
{
|
| 61 |
+
"id": "provider1",
|
| 62 |
+
"name": "Healthcare Provider Inc.",
|
| 63 |
+
"type": "provider",
|
| 64 |
+
"email": "provider@example.com",
|
| 65 |
+
"role": "data_controller"
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"id": "patient1",
|
| 69 |
+
"name": "John Doe",
|
| 70 |
+
"type": "patient",
|
| 71 |
+
"email": "patient@example.com",
|
| 72 |
+
"role": "data_subject"
|
| 73 |
+
}
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
# Create contract request
|
| 77 |
+
contract_request = ContractCreateRequest(
|
| 78 |
+
title=title,
|
| 79 |
+
description=description,
|
| 80 |
+
clauses=clauses,
|
| 81 |
+
parties=parties,
|
| 82 |
+
is_hipaa_compliant=True,
|
| 83 |
+
expires_at=datetime.utcnow() + timedelta(days=365),
|
| 84 |
+
metadata={
|
| 85 |
+
"created_by": "system",
|
| 86 |
+
"purpose": "data_sharing_agreement"
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Create contract
|
| 91 |
+
contract = await contract_engine.create_contract(contract_request, "system")
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
"contract_id": str(contract.id),
|
| 95 |
+
"title": contract.title,
|
| 96 |
+
"state": contract.state.value,
|
| 97 |
+
"parties": [party["name"] for party in contract.get_parties()],
|
| 98 |
+
"clauses": [clause.title for clause in contract.get_clauses()],
|
| 99 |
+
"is_hipaa_compliant": contract.is_hipaa_compliant
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return server
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
async def demonstrate_contract_lifecycle():
|
| 106 |
+
"""Demonstrate complete contract lifecycle."""
|
| 107 |
+
|
| 108 |
+
server = create_contract_example()
|
| 109 |
+
contract_engine = server.get_contract_engine()
|
| 110 |
+
assert contract_engine is not None # Ensure contract engine is enabled
|
| 111 |
+
|
| 112 |
+
print("📋 FastMCP Contract Management Example")
|
| 113 |
+
print("=" * 50)
|
| 114 |
+
|
| 115 |
+
# 1. Create a contract
|
| 116 |
+
print("\n1️⃣ Creating Contract")
|
| 117 |
+
print("-" * 30)
|
| 118 |
+
|
| 119 |
+
clauses = [
|
| 120 |
+
Clause(
|
| 121 |
+
title="Data Sharing Agreement",
|
| 122 |
+
content="Healthcare provider may share patient data with authorized third parties.",
|
| 123 |
+
type="hipaa"
|
| 124 |
+
),
|
| 125 |
+
Clause(
|
| 126 |
+
title="Consent Requirement",
|
| 127 |
+
content="Patient consent must be obtained before data sharing.",
|
| 128 |
+
type="consent"
|
| 129 |
+
)
|
| 130 |
+
]
|
| 131 |
+
|
| 132 |
+
parties = [
|
| 133 |
+
{
|
| 134 |
+
"id": "provider1",
|
| 135 |
+
"name": "Metro Health System",
|
| 136 |
+
"type": "provider",
|
| 137 |
+
"email": "admin@metrohealth.com"
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"id": "patient1",
|
| 141 |
+
"name": "Jane Smith",
|
| 142 |
+
"type": "patient",
|
| 143 |
+
"email": "jane.smith@email.com"
|
| 144 |
+
}
|
| 145 |
+
]
|
| 146 |
+
|
| 147 |
+
contract_request = ContractCreateRequest(
|
| 148 |
+
title="HIPAA Data Sharing Agreement",
|
| 149 |
+
description="Agreement for sharing patient data between healthcare providers",
|
| 150 |
+
clauses=clauses,
|
| 151 |
+
parties=parties,
|
| 152 |
+
is_hipaa_compliant=True,
|
| 153 |
+
expires_at=datetime.utcnow() + timedelta(days=365)
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
contract = await contract_engine.create_contract(contract_request, "admin")
|
| 157 |
+
print(f"✅ Contract created: {contract.title}")
|
| 158 |
+
print(f" ID: {contract.id}")
|
| 159 |
+
print(f" State: {contract.state.value}")
|
| 160 |
+
print(f" Parties: {len(contract.get_parties())}")
|
| 161 |
+
print(f" HIPAA Compliant: {contract.is_hipaa_compliant}")
|
| 162 |
+
|
| 163 |
+
# 2. Propose the contract
|
| 164 |
+
print("\n2️⃣ Proposing Contract")
|
| 165 |
+
print("-" * 30)
|
| 166 |
+
|
| 167 |
+
proposal_request = ContractProposeRequest(
|
| 168 |
+
proposed_to=["provider1", "patient1"],
|
| 169 |
+
message="Please review and sign this data sharing agreement."
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
contract = await contract_engine.propose_contract(contract.id, proposal_request, "admin")
|
| 173 |
+
print(f"✅ Contract proposed to: {proposal_request.proposed_to}")
|
| 174 |
+
print(f" State: {contract.state.value}")
|
| 175 |
+
print(f" Proposed at: {contract.proposed_at}")
|
| 176 |
+
|
| 177 |
+
# 3. Sign the contract (Provider)
|
| 178 |
+
print("\n3️⃣ Signing Contract (Provider)")
|
| 179 |
+
print("-" * 30)
|
| 180 |
+
|
| 181 |
+
# Generate key pair for provider
|
| 182 |
+
provider_public_key, provider_private_key = generate_key_pair()
|
| 183 |
+
provider_signer = Ed25519Signer.from_private_key_b64(provider_private_key)
|
| 184 |
+
|
| 185 |
+
# Create signing message
|
| 186 |
+
signing_message = f"{contract.id}:{contract.get_content_hash()}:provider1:provider"
|
| 187 |
+
provider_signature = provider_signer.sign(signing_message)
|
| 188 |
+
|
| 189 |
+
sign_request = ContractSignRequest(
|
| 190 |
+
signer_id="provider1",
|
| 191 |
+
signer_type="provider",
|
| 192 |
+
public_key=provider_public_key,
|
| 193 |
+
signature=provider_signature
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
contract = await contract_engine.sign_contract(contract.id, sign_request)
|
| 197 |
+
print(f"✅ Provider signed the contract")
|
| 198 |
+
print(f" Signatures: {len(contract.get_signatures())}")
|
| 199 |
+
print(f" State: {contract.state.value}")
|
| 200 |
+
|
| 201 |
+
# 4. Sign the contract (Patient)
|
| 202 |
+
print("\n4️⃣ Signing Contract (Patient)")
|
| 203 |
+
print("-" * 30)
|
| 204 |
+
|
| 205 |
+
# Generate key pair for patient
|
| 206 |
+
patient_public_key, patient_private_key = generate_key_pair()
|
| 207 |
+
patient_signer = Ed25519Signer.from_private_key_b64(patient_private_key)
|
| 208 |
+
|
| 209 |
+
# Create signing message
|
| 210 |
+
signing_message = f"{contract.id}:{contract.get_content_hash()}:patient1:patient"
|
| 211 |
+
patient_signature = patient_signer.sign(signing_message)
|
| 212 |
+
|
| 213 |
+
sign_request = ContractSignRequest(
|
| 214 |
+
signer_id="patient1",
|
| 215 |
+
signer_type="patient",
|
| 216 |
+
public_key=patient_public_key,
|
| 217 |
+
signature=patient_signature
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
contract = await contract_engine.sign_contract(contract.id, sign_request)
|
| 221 |
+
print(f"✅ Patient signed the contract")
|
| 222 |
+
print(f" Signatures: {len(contract.get_signatures())}")
|
| 223 |
+
print(f" State: {contract.state.value}")
|
| 224 |
+
print(f" Fully signed: {contract.is_fully_signed()}")
|
| 225 |
+
|
| 226 |
+
# 5. Demonstrate contract verification
|
| 227 |
+
print("\n5️⃣ Contract Verification")
|
| 228 |
+
print("-" * 30)
|
| 229 |
+
|
| 230 |
+
signatures = contract.get_signatures()
|
| 231 |
+
for signature in signatures:
|
| 232 |
+
# Verify signature
|
| 233 |
+
signing_message = f"{contract.id}:{contract.get_content_hash()}:{signature.signer_id}:{signature.signer_type}"
|
| 234 |
+
is_valid = provider_signer.verify(signing_message, signature.signature)
|
| 235 |
+
print(f" {signature.signer_id} signature: {'✅ Valid' if is_valid else '❌ Invalid'}")
|
| 236 |
+
|
| 237 |
+
# 6. Get contract statistics
|
| 238 |
+
print("\n6️⃣ Contract Statistics")
|
| 239 |
+
print("-" * 30)
|
| 240 |
+
|
| 241 |
+
stats = await contract_engine.get_contract_statistics()
|
| 242 |
+
print(f" Total contracts: {stats['total_contracts']}")
|
| 243 |
+
print(f" By state: {stats['by_state']}")
|
| 244 |
+
print(f" HIPAA compliant: {stats['hipaa_compliant']}")
|
| 245 |
+
print(f" Signed contracts: {stats['signed_contracts']}")
|
| 246 |
+
|
| 247 |
+
# 7. Demonstrate revocation (optional)
|
| 248 |
+
print("\n7️⃣ Contract Revocation (Optional)")
|
| 249 |
+
print("-" * 30)
|
| 250 |
+
|
| 251 |
+
revoke_request = ContractRevokeRequest(
|
| 252 |
+
reason="Patient requested data deletion",
|
| 253 |
+
revoked_by="admin"
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
contract = await contract_engine.revoke_contract(contract.id, revoke_request)
|
| 257 |
+
print(f"✅ Contract revoked")
|
| 258 |
+
print(f" State: {contract.state.value}")
|
| 259 |
+
print(f" Revoked at: {contract.revoked_at}")
|
| 260 |
+
print(f" Reason: {revoke_request.reason}")
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def demonstrate_hipaa_compliance():
|
| 264 |
+
"""Demonstrate HIPAA compliance features."""
|
| 265 |
+
|
| 266 |
+
print("\n🏥 HIPAA Compliance Demonstration")
|
| 267 |
+
print("=" * 50)
|
| 268 |
+
|
| 269 |
+
server = create_contract_example()
|
| 270 |
+
contract_engine = server.get_contract_engine()
|
| 271 |
+
assert contract_engine is not None
|
| 272 |
+
|
| 273 |
+
# Create HIPAA-compliant contract
|
| 274 |
+
hipaa_clauses = [
|
| 275 |
+
Clause(
|
| 276 |
+
title="HIPAA Privacy Rule",
|
| 277 |
+
content="All patient data must be handled according to HIPAA Privacy Rule requirements.",
|
| 278 |
+
type="hipaa",
|
| 279 |
+
metadata={"regulation": "45 CFR 164.502"}
|
| 280 |
+
),
|
| 281 |
+
Clause(
|
| 282 |
+
title="Minimum Necessary Standard",
|
| 283 |
+
content="Only the minimum necessary information may be disclosed.",
|
| 284 |
+
type="hipaa",
|
| 285 |
+
metadata={"regulation": "45 CFR 164.502(b)"}
|
| 286 |
+
),
|
| 287 |
+
Clause(
|
| 288 |
+
title="Business Associate Agreement",
|
| 289 |
+
content="Third parties must sign a Business Associate Agreement.",
|
| 290 |
+
type="hipaa",
|
| 291 |
+
metadata={"regulation": "45 CFR 164.502(e)"}
|
| 292 |
+
)
|
| 293 |
+
]
|
| 294 |
+
|
| 295 |
+
hipaa_parties = [
|
| 296 |
+
{
|
| 297 |
+
"id": "covered_entity",
|
| 298 |
+
"name": "Regional Medical Center",
|
| 299 |
+
"type": "covered_entity",
|
| 300 |
+
"email": "privacy@regionalmedical.com",
|
| 301 |
+
"hipaa_role": "covered_entity"
|
| 302 |
+
},
|
| 303 |
+
{
|
| 304 |
+
"id": "business_associate",
|
| 305 |
+
"name": "HealthTech Solutions",
|
| 306 |
+
"type": "business_associate",
|
| 307 |
+
"email": "compliance@healthtech.com",
|
| 308 |
+
"hipaa_role": "business_associate"
|
| 309 |
+
}
|
| 310 |
+
]
|
| 311 |
+
|
| 312 |
+
contract_request = ContractCreateRequest(
|
| 313 |
+
title="HIPAA Business Associate Agreement",
|
| 314 |
+
description="Agreement for HIPAA-compliant data processing services",
|
| 315 |
+
clauses=hipaa_clauses,
|
| 316 |
+
parties=hipaa_parties,
|
| 317 |
+
is_hipaa_compliant=True,
|
| 318 |
+
hipaa_entities=[
|
| 319 |
+
{
|
| 320 |
+
"type": "covered_entity",
|
| 321 |
+
"name": "Regional Medical Center",
|
| 322 |
+
"hipaa_id": "CE-001"
|
| 323 |
+
},
|
| 324 |
+
{
|
| 325 |
+
"type": "business_associate",
|
| 326 |
+
"name": "HealthTech Solutions",
|
| 327 |
+
"hipaa_id": "BA-001"
|
| 328 |
+
}
|
| 329 |
+
],
|
| 330 |
+
metadata={
|
| 331 |
+
"hipaa_version": "2023",
|
| 332 |
+
"compliance_level": "full",
|
| 333 |
+
"audit_required": True
|
| 334 |
+
}
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
contract = await contract_engine.create_contract(contract_request, "hipaa_admin")
|
| 338 |
+
|
| 339 |
+
print(f"✅ HIPAA-compliant contract created")
|
| 340 |
+
print(f" Title: {contract.title}")
|
| 341 |
+
print(f" HIPAA Compliant: {contract.is_hipaa_compliant}")
|
| 342 |
+
print(f" HIPAA Entities: {len(contract.get_hipaa_entities())}")
|
| 343 |
+
print(f" Clauses: {len(contract.get_clauses())}")
|
| 344 |
+
|
| 345 |
+
# Show HIPAA-specific metadata
|
| 346 |
+
metadata = contract.get_metadata()
|
| 347 |
+
print(f" Compliance Level: {metadata.get('compliance_level')}")
|
| 348 |
+
print(f" Audit Required: {metadata.get('audit_required')}")
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def main():
|
| 352 |
+
"""Run the contract management example."""
|
| 353 |
+
|
| 354 |
+
async def run_example():
|
| 355 |
+
# Demonstrate contract lifecycle
|
| 356 |
+
await demonstrate_contract_lifecycle()
|
| 357 |
+
|
| 358 |
+
# Demonstrate HIPAA compliance
|
| 359 |
+
await demonstrate_hipaa_compliance()
|
| 360 |
+
|
| 361 |
+
print("\n🚀 Contract Management Example Complete!")
|
| 362 |
+
print("\nTo test the HTTP endpoints:")
|
| 363 |
+
print("1. Run: fastmcp run examples/contract_example.py --transport http")
|
| 364 |
+
print("2. Available endpoints:")
|
| 365 |
+
print(" - POST /contracts - Create contract")
|
| 366 |
+
print(" - GET /contracts - List contracts")
|
| 367 |
+
print(" - GET /contracts/{id} - Get contract")
|
| 368 |
+
print(" - POST /contracts/{id}/propose - Propose contract")
|
| 369 |
+
print(" - POST /contracts/{id}/sign - Sign contract")
|
| 370 |
+
print(" - POST /contracts/{id}/revoke - Revoke contract")
|
| 371 |
+
print(" - GET /contracts/statistics - Get statistics")
|
| 372 |
+
print("\n3. Example contract creation:")
|
| 373 |
+
print("""
|
| 374 |
+
curl -X POST http://localhost:8000/contracts \\
|
| 375 |
+
-H "Content-Type: application/json" \\
|
| 376 |
+
-d '{
|
| 377 |
+
"title": "Test Contract",
|
| 378 |
+
"description": "A test contract",
|
| 379 |
+
"clauses": [
|
| 380 |
+
{
|
| 381 |
+
"title": "Test Clause",
|
| 382 |
+
"content": "This is a test clause",
|
| 383 |
+
"type": "test"
|
| 384 |
+
}
|
| 385 |
+
],
|
| 386 |
+
"parties": [
|
| 387 |
+
{
|
| 388 |
+
"id": "party1",
|
| 389 |
+
"name": "Test Party",
|
| 390 |
+
"type": "provider"
|
| 391 |
+
}
|
| 392 |
+
],
|
| 393 |
+
"is_hipaa_compliant": false
|
| 394 |
+
}'
|
| 395 |
+
""")
|
| 396 |
+
|
| 397 |
+
asyncio.run(run_example())
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
if __name__ == "__main__":
|
| 401 |
+
main()
|
src/fastmcp/contracts/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastMCP Contract Management - Inter-agent contract lifecycle with cryptographic guarantees."""
|
| 2 |
+
|
| 3 |
+
from .contract import Contract, Clause, Signature, ContractState
|
| 4 |
+
from .engine import ContractEngine
|
| 5 |
+
from .registry import ContractRegistry
|
| 6 |
+
|
| 7 |
+
__all__ = ["Contract", "Clause", "Signature", "ContractState", "ContractEngine", "ContractRegistry"]
|
src/fastmcp/contracts/contract.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contract models and schemas for inter-agent contract management."""
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from enum import Enum
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
from uuid import UUID, uuid4
|
| 9 |
+
|
| 10 |
+
from pydantic import BaseModel, Field, validator
|
| 11 |
+
from sqlmodel import SQLModel, Field as SQLField, Relationship
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ContractState(str, Enum):
|
| 15 |
+
"""Contract lifecycle states."""
|
| 16 |
+
DRAFT = "draft"
|
| 17 |
+
PROPOSED = "proposed"
|
| 18 |
+
SIGNED = "signed"
|
| 19 |
+
REVOKED = "revoked"
|
| 20 |
+
EXPIRED = "expired"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Clause(BaseModel):
|
| 24 |
+
"""A contract clause with structured content."""
|
| 25 |
+
|
| 26 |
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
| 27 |
+
title: str = Field(..., description="Clause title")
|
| 28 |
+
content: str = Field(..., description="Clause content")
|
| 29 |
+
type: str = Field(default="general", description="Clause type (e.g., 'hipaa', 'data_handling')")
|
| 30 |
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional clause metadata")
|
| 31 |
+
|
| 32 |
+
class Config:
|
| 33 |
+
json_encoders = {
|
| 34 |
+
datetime: lambda v: v.isoformat()
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class Signature(BaseModel):
|
| 39 |
+
"""Cryptographic signature for contract verification."""
|
| 40 |
+
|
| 41 |
+
signer_id: str = Field(..., description="ID of the signing party")
|
| 42 |
+
signer_type: str = Field(..., description="Type of signer (e.g., 'provider', 'payor', 'patient')")
|
| 43 |
+
signature: str = Field(..., description="Base64-encoded Ed25519 signature")
|
| 44 |
+
public_key: str = Field(..., description="Base64-encoded public key")
|
| 45 |
+
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
| 46 |
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
| 47 |
+
|
| 48 |
+
class Config:
|
| 49 |
+
json_encoders = {
|
| 50 |
+
datetime: lambda v: v.isoformat()
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
def model_dump(self, **kwargs):
|
| 54 |
+
"""Override model_dump to handle datetime serialization."""
|
| 55 |
+
data = super().model_dump(**kwargs)
|
| 56 |
+
# Convert datetime objects to ISO format strings
|
| 57 |
+
for key, value in data.items():
|
| 58 |
+
if isinstance(value, datetime):
|
| 59 |
+
data[key] = value.isoformat()
|
| 60 |
+
return data
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class Contract(SQLModel, table=True):
|
| 64 |
+
"""Contract model with SQLModel persistence."""
|
| 65 |
+
|
| 66 |
+
__tablename__ = "contracts"
|
| 67 |
+
|
| 68 |
+
# Primary fields
|
| 69 |
+
id: UUID = SQLField(default_factory=uuid4, primary_key=True)
|
| 70 |
+
title: str = SQLField(..., description="Contract title")
|
| 71 |
+
description: str = SQLField(..., description="Contract description")
|
| 72 |
+
|
| 73 |
+
# Contract content
|
| 74 |
+
clauses: str = SQLField(..., description="JSON-encoded clauses")
|
| 75 |
+
parties: str = SQLField(..., description="JSON-encoded parties")
|
| 76 |
+
|
| 77 |
+
# Lifecycle
|
| 78 |
+
state: ContractState = SQLField(default=ContractState.DRAFT)
|
| 79 |
+
created_at: datetime = SQLField(default_factory=datetime.utcnow)
|
| 80 |
+
proposed_at: Optional[datetime] = SQLField(default=None)
|
| 81 |
+
signed_at: Optional[datetime] = SQLField(default=None)
|
| 82 |
+
revoked_at: Optional[datetime] = SQLField(default=None)
|
| 83 |
+
expires_at: Optional[datetime] = SQLField(default=None)
|
| 84 |
+
|
| 85 |
+
# Signatures
|
| 86 |
+
signatures: str = SQLField(default="[]", description="JSON-encoded signatures")
|
| 87 |
+
|
| 88 |
+
# HIPAA compliance
|
| 89 |
+
is_hipaa_compliant: bool = SQLField(default=False)
|
| 90 |
+
hipaa_entities: str = SQLField(default="[]", description="JSON-encoded HIPAA entities")
|
| 91 |
+
|
| 92 |
+
# Metadata
|
| 93 |
+
contract_metadata: str = SQLField(default="{}", description="JSON-encoded metadata")
|
| 94 |
+
version: str = SQLField(default="1.0.0")
|
| 95 |
+
|
| 96 |
+
# Audit trail
|
| 97 |
+
created_by: str = SQLField(..., description="ID of the creating party")
|
| 98 |
+
last_modified: datetime = SQLField(default_factory=datetime.utcnow)
|
| 99 |
+
|
| 100 |
+
def get_clauses(self) -> List[Clause]:
|
| 101 |
+
"""Get parsed clauses from JSON."""
|
| 102 |
+
try:
|
| 103 |
+
clauses_data = json.loads(self.clauses)
|
| 104 |
+
return [Clause(**clause) for clause in clauses_data]
|
| 105 |
+
except (json.JSONDecodeError, ValueError):
|
| 106 |
+
return []
|
| 107 |
+
|
| 108 |
+
def set_clauses(self, clauses: List[Clause]) -> None:
|
| 109 |
+
"""Set clauses as JSON."""
|
| 110 |
+
self.clauses = json.dumps([clause.model_dump() for clause in clauses])
|
| 111 |
+
|
| 112 |
+
def get_parties(self) -> List[Dict[str, Any]]:
|
| 113 |
+
"""Get parsed parties from JSON."""
|
| 114 |
+
try:
|
| 115 |
+
return json.loads(self.parties)
|
| 116 |
+
except json.JSONDecodeError:
|
| 117 |
+
return []
|
| 118 |
+
|
| 119 |
+
def set_parties(self, parties: List[Dict[str, Any]]) -> None:
|
| 120 |
+
"""Set parties as JSON."""
|
| 121 |
+
self.parties = json.dumps(parties)
|
| 122 |
+
|
| 123 |
+
def get_signatures(self) -> List[Signature]:
|
| 124 |
+
"""Get parsed signatures from JSON."""
|
| 125 |
+
try:
|
| 126 |
+
signatures_data = json.loads(self.signatures)
|
| 127 |
+
return [Signature(**sig) for sig in signatures_data]
|
| 128 |
+
except (json.JSONDecodeError, ValueError):
|
| 129 |
+
return []
|
| 130 |
+
|
| 131 |
+
def set_signatures(self, signatures: List[Signature]) -> None:
|
| 132 |
+
"""Set signatures as JSON."""
|
| 133 |
+
self.signatures = json.dumps([sig.model_dump() for sig in signatures])
|
| 134 |
+
|
| 135 |
+
def get_hipaa_entities(self) -> List[Dict[str, Any]]:
|
| 136 |
+
"""Get parsed HIPAA entities from JSON."""
|
| 137 |
+
try:
|
| 138 |
+
return json.loads(self.hipaa_entities)
|
| 139 |
+
except json.JSONDecodeError:
|
| 140 |
+
return []
|
| 141 |
+
|
| 142 |
+
def set_hipaa_entities(self, entities: List[Dict[str, Any]]) -> None:
|
| 143 |
+
"""Set HIPAA entities as JSON."""
|
| 144 |
+
self.hipaa_entities = json.dumps(entities)
|
| 145 |
+
|
| 146 |
+
def get_metadata(self) -> Dict[str, Any]:
|
| 147 |
+
"""Get parsed metadata from JSON."""
|
| 148 |
+
try:
|
| 149 |
+
return json.loads(self.contract_metadata)
|
| 150 |
+
except json.JSONDecodeError:
|
| 151 |
+
return {}
|
| 152 |
+
|
| 153 |
+
def set_metadata(self, metadata: Dict[str, Any]) -> None:
|
| 154 |
+
"""Set metadata as JSON."""
|
| 155 |
+
self.contract_metadata = json.dumps(metadata)
|
| 156 |
+
|
| 157 |
+
def get_content_hash(self) -> str:
|
| 158 |
+
"""Get SHA-256 hash of contract content for signing."""
|
| 159 |
+
content = {
|
| 160 |
+
"id": str(self.id),
|
| 161 |
+
"title": self.title,
|
| 162 |
+
"description": self.description,
|
| 163 |
+
"clauses": self.clauses,
|
| 164 |
+
"parties": self.parties,
|
| 165 |
+
"version": self.version
|
| 166 |
+
}
|
| 167 |
+
content_str = json.dumps(content, sort_keys=True)
|
| 168 |
+
return hashlib.sha256(content_str.encode()).hexdigest()
|
| 169 |
+
|
| 170 |
+
def can_transition_to(self, new_state: ContractState) -> bool:
|
| 171 |
+
"""Check if contract can transition to new state."""
|
| 172 |
+
valid_transitions = {
|
| 173 |
+
ContractState.DRAFT: [ContractState.PROPOSED, ContractState.REVOKED],
|
| 174 |
+
ContractState.PROPOSED: [ContractState.SIGNED, ContractState.REVOKED, ContractState.DRAFT],
|
| 175 |
+
ContractState.SIGNED: [ContractState.REVOKED],
|
| 176 |
+
ContractState.REVOKED: [], # Terminal state
|
| 177 |
+
ContractState.EXPIRED: [] # Terminal state
|
| 178 |
+
}
|
| 179 |
+
return new_state in valid_transitions.get(self.state, [])
|
| 180 |
+
|
| 181 |
+
def is_fully_signed(self) -> bool:
|
| 182 |
+
"""Check if contract is fully signed by all required parties."""
|
| 183 |
+
parties = self.get_parties()
|
| 184 |
+
signatures = self.get_signatures()
|
| 185 |
+
|
| 186 |
+
# Check if all parties have signed
|
| 187 |
+
signed_party_ids = {sig.signer_id for sig in signatures}
|
| 188 |
+
required_party_ids = {party["id"] for party in parties}
|
| 189 |
+
|
| 190 |
+
return required_party_ids.issubset(signed_party_ids)
|
| 191 |
+
|
| 192 |
+
def get_unsigned_parties(self) -> List[Dict[str, Any]]:
|
| 193 |
+
"""Get parties that haven't signed yet."""
|
| 194 |
+
parties = self.get_parties()
|
| 195 |
+
signatures = self.get_signatures()
|
| 196 |
+
signed_party_ids = {sig.signer_id for sig in signatures}
|
| 197 |
+
|
| 198 |
+
return [party for party in parties if party["id"] not in signed_party_ids]
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class ContractCreateRequest(BaseModel):
|
| 202 |
+
"""Request model for creating a contract."""
|
| 203 |
+
|
| 204 |
+
title: str = Field(..., description="Contract title")
|
| 205 |
+
description: str = Field(..., description="Contract description")
|
| 206 |
+
clauses: List[Clause] = Field(..., description="Contract clauses")
|
| 207 |
+
parties: List[Dict[str, Any]] = Field(..., description="Contract parties")
|
| 208 |
+
is_hipaa_compliant: bool = Field(default=False, description="HIPAA compliance flag")
|
| 209 |
+
hipaa_entities: Optional[List[Dict[str, Any]]] = Field(default=None, description="HIPAA entities")
|
| 210 |
+
expires_at: Optional[datetime] = Field(default=None, description="Contract expiration")
|
| 211 |
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
| 212 |
+
version: str = Field(default="1.0.0", description="Contract version")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class ContractProposeRequest(BaseModel):
|
| 216 |
+
"""Request model for proposing a contract."""
|
| 217 |
+
|
| 218 |
+
proposed_to: List[str] = Field(..., description="IDs of parties to propose to")
|
| 219 |
+
message: Optional[str] = Field(default=None, description="Proposal message")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class ContractSignRequest(BaseModel):
|
| 223 |
+
"""Request model for signing a contract."""
|
| 224 |
+
|
| 225 |
+
signer_id: str = Field(..., description="ID of the signing party")
|
| 226 |
+
signer_type: str = Field(..., description="Type of signer")
|
| 227 |
+
public_key: str = Field(..., description="Base64-encoded public key")
|
| 228 |
+
signature: str = Field(..., description="Base64-encoded Ed25519 signature")
|
| 229 |
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Signature metadata")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
class ContractRevokeRequest(BaseModel):
|
| 233 |
+
"""Request model for revoking a contract."""
|
| 234 |
+
|
| 235 |
+
reason: str = Field(..., description="Reason for revocation")
|
| 236 |
+
revoked_by: str = Field(..., description="ID of the revoking party")
|
| 237 |
+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Revocation metadata")
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
class ContractResponse(BaseModel):
|
| 241 |
+
"""Response model for contract operations."""
|
| 242 |
+
|
| 243 |
+
id: str
|
| 244 |
+
title: str
|
| 245 |
+
description: str
|
| 246 |
+
clauses: List[Clause]
|
| 247 |
+
parties: List[Dict[str, Any]]
|
| 248 |
+
state: ContractState
|
| 249 |
+
created_at: datetime
|
| 250 |
+
proposed_at: Optional[datetime]
|
| 251 |
+
signed_at: Optional[datetime]
|
| 252 |
+
revoked_at: Optional[datetime]
|
| 253 |
+
expires_at: Optional[datetime]
|
| 254 |
+
signatures: List[Dict[str, Any]]
|
| 255 |
+
is_hipaa_compliant: bool
|
| 256 |
+
hipaa_entities: List[Dict[str, Any]]
|
| 257 |
+
contract_metadata: Dict[str, Any]
|
| 258 |
+
version: str
|
| 259 |
+
created_by: str
|
| 260 |
+
last_modified: datetime
|
| 261 |
+
content_hash: str
|
| 262 |
+
is_fully_signed: bool
|
| 263 |
+
unsigned_parties: List[Dict[str, Any]]
|
| 264 |
+
|
| 265 |
+
def model_dump(self, **kwargs):
|
| 266 |
+
"""Override model_dump to handle datetime serialization."""
|
| 267 |
+
data = super().model_dump(**kwargs)
|
| 268 |
+
# Convert datetime objects to ISO format strings
|
| 269 |
+
for key, value in data.items():
|
| 270 |
+
if isinstance(value, datetime):
|
| 271 |
+
data[key] = value.isoformat()
|
| 272 |
+
elif key == "signatures" and isinstance(value, list):
|
| 273 |
+
# Handle nested Signature objects
|
| 274 |
+
data[key] = [sig.model_dump() if hasattr(sig, 'model_dump') else sig for sig in value]
|
| 275 |
+
return data
|
| 276 |
+
|
| 277 |
+
@classmethod
|
| 278 |
+
def from_contract(cls, contract: Contract) -> "ContractResponse":
|
| 279 |
+
"""Create response from contract model."""
|
| 280 |
+
# Pre-serialize signatures to handle datetime fields
|
| 281 |
+
signatures = contract.get_signatures()
|
| 282 |
+
serialized_signatures = [sig.model_dump() for sig in signatures]
|
| 283 |
+
|
| 284 |
+
return cls(
|
| 285 |
+
id=str(contract.id), # Convert UUID to string
|
| 286 |
+
title=contract.title,
|
| 287 |
+
description=contract.description,
|
| 288 |
+
clauses=contract.get_clauses(),
|
| 289 |
+
parties=contract.get_parties(),
|
| 290 |
+
state=contract.state,
|
| 291 |
+
created_at=contract.created_at,
|
| 292 |
+
proposed_at=contract.proposed_at,
|
| 293 |
+
signed_at=contract.signed_at,
|
| 294 |
+
revoked_at=contract.revoked_at,
|
| 295 |
+
expires_at=contract.expires_at,
|
| 296 |
+
signatures=serialized_signatures, # Use pre-serialized signatures
|
| 297 |
+
is_hipaa_compliant=contract.is_hipaa_compliant,
|
| 298 |
+
hipaa_entities=contract.get_hipaa_entities(),
|
| 299 |
+
contract_metadata=contract.get_metadata(),
|
| 300 |
+
version=contract.version,
|
| 301 |
+
created_by=contract.created_by,
|
| 302 |
+
last_modified=contract.last_modified,
|
| 303 |
+
content_hash=contract.get_content_hash(),
|
| 304 |
+
is_fully_signed=contract.is_fully_signed(),
|
| 305 |
+
unsigned_parties=contract.get_unsigned_parties()
|
| 306 |
+
)
|
src/fastmcp/contracts/crypto.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cryptographic utilities for contract signing and verification."""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from typing import Tuple
|
| 5 |
+
|
| 6 |
+
import nacl.encoding
|
| 7 |
+
import nacl.signing
|
| 8 |
+
from nacl.exceptions import BadSignatureError
|
| 9 |
+
|
| 10 |
+
from fastmcp.utilities.logging import get_logger
|
| 11 |
+
|
| 12 |
+
logger = get_logger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class CryptoError(Exception):
|
| 16 |
+
"""Cryptographic operation error."""
|
| 17 |
+
pass
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Ed25519Signer:
|
| 21 |
+
"""Ed25519 signature operations for contract signing."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, private_key: bytes = None):
|
| 24 |
+
"""Initialize signer with optional private key.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
private_key: Optional private key bytes. If None, generates new key pair.
|
| 28 |
+
"""
|
| 29 |
+
if private_key is None:
|
| 30 |
+
self._signing_key = nacl.signing.SigningKey.generate()
|
| 31 |
+
else:
|
| 32 |
+
self._signing_key = nacl.signing.SigningKey(private_key)
|
| 33 |
+
|
| 34 |
+
self._verify_key = self._signing_key.verify_key
|
| 35 |
+
|
| 36 |
+
@classmethod
|
| 37 |
+
def from_private_key_b64(cls, private_key_b64: str) -> "Ed25519Signer":
|
| 38 |
+
"""Create signer from base64-encoded private key.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
private_key_b64: Base64-encoded private key
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
Ed25519Signer instance
|
| 45 |
+
"""
|
| 46 |
+
try:
|
| 47 |
+
private_key = base64.b64decode(private_key_b64)
|
| 48 |
+
return cls(private_key)
|
| 49 |
+
except Exception as e:
|
| 50 |
+
raise CryptoError(f"Invalid private key format: {e}")
|
| 51 |
+
|
| 52 |
+
@classmethod
|
| 53 |
+
def from_public_key_b64(cls, public_key_b64: str) -> "Ed25519Signer":
|
| 54 |
+
"""Create signer from base64-encoded public key (verification only).
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
public_key_b64: Base64-encoded public key
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
Ed25519Signer instance (verification only)
|
| 61 |
+
"""
|
| 62 |
+
try:
|
| 63 |
+
public_key = base64.b64decode(public_key_b64)
|
| 64 |
+
verify_key = nacl.signing.VerifyKey(public_key)
|
| 65 |
+
signer = cls.__new__(cls)
|
| 66 |
+
signer._signing_key = None # No private key for verification only
|
| 67 |
+
signer._verify_key = verify_key
|
| 68 |
+
return signer
|
| 69 |
+
except Exception as e:
|
| 70 |
+
raise CryptoError(f"Invalid public key format: {e}")
|
| 71 |
+
|
| 72 |
+
def sign(self, message: str) -> str:
|
| 73 |
+
"""Sign a message.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
message: Message to sign
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
Base64-encoded signature
|
| 80 |
+
|
| 81 |
+
Raises:
|
| 82 |
+
CryptoError: If signing fails
|
| 83 |
+
"""
|
| 84 |
+
if self._signing_key is None:
|
| 85 |
+
raise CryptoError("Cannot sign: no private key available")
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
message_bytes = message.encode('utf-8')
|
| 89 |
+
signed = self._signing_key.sign(message_bytes)
|
| 90 |
+
signature = signed.signature
|
| 91 |
+
return base64.b64encode(signature).decode('ascii')
|
| 92 |
+
except Exception as e:
|
| 93 |
+
raise CryptoError(f"Signing failed: {e}")
|
| 94 |
+
|
| 95 |
+
def verify(self, message: str, signature: str) -> bool:
|
| 96 |
+
"""Verify a signature.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
message: Original message
|
| 100 |
+
signature: Base64-encoded signature to verify
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
True if signature is valid, False otherwise
|
| 104 |
+
"""
|
| 105 |
+
try:
|
| 106 |
+
message_bytes = message.encode('utf-8')
|
| 107 |
+
signature_bytes = base64.b64decode(signature)
|
| 108 |
+
self._verify_key.verify(message_bytes, signature_bytes)
|
| 109 |
+
return True
|
| 110 |
+
except (BadSignatureError, Exception) as e:
|
| 111 |
+
logger.debug(f"Signature verification failed: {e}")
|
| 112 |
+
return False
|
| 113 |
+
|
| 114 |
+
def get_public_key_b64(self) -> str:
|
| 115 |
+
"""Get base64-encoded public key.
|
| 116 |
+
|
| 117 |
+
Returns:
|
| 118 |
+
Base64-encoded public key
|
| 119 |
+
"""
|
| 120 |
+
return base64.b64encode(self._verify_key.encode()).decode('ascii')
|
| 121 |
+
|
| 122 |
+
def get_private_key_b64(self) -> str:
|
| 123 |
+
"""Get base64-encoded private key.
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Base64-encoded private key
|
| 127 |
+
|
| 128 |
+
Raises:
|
| 129 |
+
CryptoError: If no private key available
|
| 130 |
+
"""
|
| 131 |
+
if self._signing_key is None:
|
| 132 |
+
raise CryptoError("No private key available")
|
| 133 |
+
|
| 134 |
+
return base64.b64encode(self._signing_key.encode()).decode('ascii')
|
| 135 |
+
|
| 136 |
+
def get_key_pair_b64(self) -> Tuple[str, str]:
|
| 137 |
+
"""Get both public and private keys as base64 strings.
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
Tuple of (public_key_b64, private_key_b64)
|
| 141 |
+
|
| 142 |
+
Raises:
|
| 143 |
+
CryptoError: If no private key available
|
| 144 |
+
"""
|
| 145 |
+
return self.get_public_key_b64(), self.get_private_key_b64()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
class ContractSigner:
|
| 149 |
+
"""High-level contract signing operations."""
|
| 150 |
+
|
| 151 |
+
def __init__(self, signer: Ed25519Signer):
|
| 152 |
+
"""Initialize with Ed25519 signer.
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
signer: Ed25519Signer instance
|
| 156 |
+
"""
|
| 157 |
+
self._signer = signer
|
| 158 |
+
|
| 159 |
+
def sign_contract(self, contract_id: str, content_hash: str, signer_id: str, signer_type: str) -> str:
|
| 160 |
+
"""Sign a contract.
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
contract_id: Contract ID
|
| 164 |
+
content_hash: SHA-256 hash of contract content
|
| 165 |
+
signer_id: ID of the signing party
|
| 166 |
+
signer_type: Type of signer (e.g., 'provider', 'payor', 'patient')
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
Base64-encoded signature
|
| 170 |
+
|
| 171 |
+
Raises:
|
| 172 |
+
CryptoError: If signing fails
|
| 173 |
+
"""
|
| 174 |
+
# Create signing message
|
| 175 |
+
signing_message = f"{contract_id}:{content_hash}:{signer_id}:{signer_type}"
|
| 176 |
+
return self._signer.sign(signing_message)
|
| 177 |
+
|
| 178 |
+
def verify_contract_signature(self, contract_id: str, content_hash: str, signer_id: str,
|
| 179 |
+
signer_type: str, signature: str) -> bool:
|
| 180 |
+
"""Verify a contract signature.
|
| 181 |
+
|
| 182 |
+
Args:
|
| 183 |
+
contract_id: Contract ID
|
| 184 |
+
content_hash: SHA-256 hash of contract content
|
| 185 |
+
signer_id: ID of the signing party
|
| 186 |
+
signer_type: Type of signer
|
| 187 |
+
signature: Base64-encoded signature to verify
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
True if signature is valid, False otherwise
|
| 191 |
+
"""
|
| 192 |
+
# Create signing message
|
| 193 |
+
signing_message = f"{contract_id}:{content_hash}:{signer_id}:{signer_type}"
|
| 194 |
+
return self._signer.verify(signing_message, signature)
|
| 195 |
+
|
| 196 |
+
def get_public_key_b64(self) -> str:
|
| 197 |
+
"""Get base64-encoded public key."""
|
| 198 |
+
return self._signer.get_public_key_b64()
|
| 199 |
+
|
| 200 |
+
def get_private_key_b64(self) -> str:
|
| 201 |
+
"""Get base64-encoded private key."""
|
| 202 |
+
return self._signer.get_private_key_b64()
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def generate_key_pair() -> Tuple[str, str]:
|
| 206 |
+
"""Generate a new Ed25519 key pair.
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
Tuple of (public_key_b64, private_key_b64)
|
| 210 |
+
"""
|
| 211 |
+
signer = Ed25519Signer()
|
| 212 |
+
return signer.get_key_pair_b64()
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def verify_signature(public_key_b64: str, message: str, signature: str) -> bool:
|
| 216 |
+
"""Verify a signature with a public key.
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
public_key_b64: Base64-encoded public key
|
| 220 |
+
message: Original message
|
| 221 |
+
signature: Base64-encoded signature
|
| 222 |
+
|
| 223 |
+
Returns:
|
| 224 |
+
True if signature is valid, False otherwise
|
| 225 |
+
"""
|
| 226 |
+
try:
|
| 227 |
+
signer = Ed25519Signer.from_public_key_b64(public_key_b64)
|
| 228 |
+
return signer.verify(message, signature)
|
| 229 |
+
except CryptoError:
|
| 230 |
+
return False
|
src/fastmcp/contracts/engine.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contract engine for managing contract lifecycle and operations."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
from uuid import UUID
|
| 6 |
+
|
| 7 |
+
from sqlmodel import Session, create_engine
|
| 8 |
+
|
| 9 |
+
from fastmcp.utilities.logging import get_logger
|
| 10 |
+
|
| 11 |
+
from .contract import Contract, ContractState, ContractCreateRequest, ContractProposeRequest, ContractSignRequest, ContractRevokeRequest
|
| 12 |
+
from .crypto import ContractSigner, CryptoError
|
| 13 |
+
from .registry import ContractRegistry
|
| 14 |
+
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ContractEngine:
|
| 19 |
+
"""Engine for managing contract lifecycle and operations."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, database_url: str = "sqlite:///contracts.db"):
|
| 22 |
+
"""Initialize contract engine with database connection.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
database_url: Database connection URL
|
| 26 |
+
"""
|
| 27 |
+
self.engine = create_engine(database_url, echo=False)
|
| 28 |
+
self._create_tables()
|
| 29 |
+
|
| 30 |
+
def _create_tables(self):
|
| 31 |
+
"""Create database tables."""
|
| 32 |
+
try:
|
| 33 |
+
Contract.metadata.create_all(self.engine)
|
| 34 |
+
logger.info("Contract database tables created/verified")
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"Failed to create contract tables: {e}")
|
| 37 |
+
raise
|
| 38 |
+
|
| 39 |
+
def get_session(self) -> Session:
|
| 40 |
+
"""Get database session.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
SQLModel database session
|
| 44 |
+
"""
|
| 45 |
+
return Session(self.engine)
|
| 46 |
+
|
| 47 |
+
def get_registry(self) -> ContractRegistry:
|
| 48 |
+
"""Get contract registry.
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
Contract registry instance
|
| 52 |
+
"""
|
| 53 |
+
return ContractRegistry(self.get_session())
|
| 54 |
+
|
| 55 |
+
async def create_contract(self, request: ContractCreateRequest, created_by: str) -> Contract:
|
| 56 |
+
"""Create a new contract.
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
request: Contract creation request
|
| 60 |
+
created_by: ID of the creating party
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Created contract instance
|
| 64 |
+
|
| 65 |
+
Raises:
|
| 66 |
+
ValueError: If contract creation fails
|
| 67 |
+
"""
|
| 68 |
+
try:
|
| 69 |
+
registry = self.get_registry()
|
| 70 |
+
|
| 71 |
+
# Convert request to contract data
|
| 72 |
+
contract_data = {
|
| 73 |
+
"title": request.title,
|
| 74 |
+
"description": request.description,
|
| 75 |
+
"clauses": [clause.model_dump() for clause in request.clauses],
|
| 76 |
+
"parties": request.parties,
|
| 77 |
+
"is_hipaa_compliant": request.is_hipaa_compliant,
|
| 78 |
+
"hipaa_entities": request.hipaa_entities or [],
|
| 79 |
+
"expires_at": request.expires_at,
|
| 80 |
+
"metadata": request.metadata,
|
| 81 |
+
"version": request.version
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
contract = registry.create_contract(contract_data, created_by)
|
| 85 |
+
logger.info(f"Created contract {contract.id} by {created_by}")
|
| 86 |
+
return contract
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Failed to create contract: {e}")
|
| 90 |
+
raise
|
| 91 |
+
|
| 92 |
+
async def get_contract(self, contract_id: UUID) -> Optional[Contract]:
|
| 93 |
+
"""Get a contract by ID.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
contract_id: Contract UUID
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
Contract instance or None if not found
|
| 100 |
+
"""
|
| 101 |
+
try:
|
| 102 |
+
registry = self.get_registry()
|
| 103 |
+
return registry.get_contract(contract_id)
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"Failed to get contract {contract_id}: {e}")
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
async def list_contracts(self, state: Optional[ContractState] = None,
|
| 109 |
+
created_by: Optional[str] = None) -> List[Contract]:
|
| 110 |
+
"""List contracts with optional filtering.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
state: Optional state filter
|
| 114 |
+
created_by: Optional creator filter
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
List of contract instances
|
| 118 |
+
"""
|
| 119 |
+
try:
|
| 120 |
+
registry = self.get_registry()
|
| 121 |
+
return registry.list_contracts(state, created_by)
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error(f"Failed to list contracts: {e}")
|
| 124 |
+
return []
|
| 125 |
+
|
| 126 |
+
async def propose_contract(self, contract_id: UUID, request: ContractProposeRequest,
|
| 127 |
+
proposed_by: str) -> Optional[Contract]:
|
| 128 |
+
"""Propose a contract to parties.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
contract_id: Contract UUID
|
| 132 |
+
request: Proposal request
|
| 133 |
+
proposed_by: ID of the proposing party
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Updated contract instance or None if not found
|
| 137 |
+
|
| 138 |
+
Raises:
|
| 139 |
+
ValueError: If proposal fails
|
| 140 |
+
"""
|
| 141 |
+
try:
|
| 142 |
+
registry = self.get_registry()
|
| 143 |
+
|
| 144 |
+
# Validate contract exists and can be proposed
|
| 145 |
+
contract = registry.get_contract(contract_id)
|
| 146 |
+
if not contract:
|
| 147 |
+
raise ValueError("Contract not found")
|
| 148 |
+
|
| 149 |
+
if contract.state != ContractState.DRAFT:
|
| 150 |
+
raise ValueError(f"Cannot propose contract in state {contract.state}")
|
| 151 |
+
|
| 152 |
+
# Update metadata with proposal info
|
| 153 |
+
metadata = {
|
| 154 |
+
"proposal": {
|
| 155 |
+
"proposed_to": request.proposed_to,
|
| 156 |
+
"message": request.message,
|
| 157 |
+
"proposed_by": proposed_by,
|
| 158 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
# Update state to proposed
|
| 163 |
+
updated_contract = registry.update_contract_state(
|
| 164 |
+
contract_id, ContractState.PROPOSED, proposed_by, metadata
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
logger.info(f"Proposed contract {contract_id} to {request.proposed_to} by {proposed_by}")
|
| 168 |
+
return updated_contract
|
| 169 |
+
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error(f"Failed to propose contract {contract_id}: {e}")
|
| 172 |
+
raise
|
| 173 |
+
|
| 174 |
+
async def sign_contract(self, contract_id: UUID, request: ContractSignRequest) -> Optional[Contract]:
|
| 175 |
+
"""Sign a contract.
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
contract_id: Contract UUID
|
| 179 |
+
request: Signing request
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
Updated contract instance or None if not found
|
| 183 |
+
|
| 184 |
+
Raises:
|
| 185 |
+
ValueError: If signing fails
|
| 186 |
+
"""
|
| 187 |
+
try:
|
| 188 |
+
registry = self.get_registry()
|
| 189 |
+
|
| 190 |
+
# Validate contract exists and can be signed
|
| 191 |
+
contract = registry.get_contract(contract_id)
|
| 192 |
+
if not contract:
|
| 193 |
+
raise ValueError("Contract not found")
|
| 194 |
+
|
| 195 |
+
if contract.state not in [ContractState.PROPOSED, ContractState.SIGNED]:
|
| 196 |
+
raise ValueError(f"Cannot sign contract in state {contract.state}")
|
| 197 |
+
|
| 198 |
+
# Check if party is already signed
|
| 199 |
+
existing_signatures = contract.get_signatures()
|
| 200 |
+
if any(sig.signer_id == request.signer_id for sig in existing_signatures):
|
| 201 |
+
raise ValueError("Party has already signed this contract")
|
| 202 |
+
|
| 203 |
+
# Verify signature
|
| 204 |
+
if not self._verify_contract_signature(contract, request):
|
| 205 |
+
raise ValueError("Invalid signature")
|
| 206 |
+
|
| 207 |
+
# Create signature object
|
| 208 |
+
from .contract import Signature
|
| 209 |
+
signature = Signature(
|
| 210 |
+
signer_id=request.signer_id,
|
| 211 |
+
signer_type=request.signer_type,
|
| 212 |
+
signature=request.signature,
|
| 213 |
+
public_key=request.public_key,
|
| 214 |
+
metadata=request.metadata
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# Add signature
|
| 218 |
+
updated_contract = registry.add_signature(contract_id, signature.model_dump())
|
| 219 |
+
|
| 220 |
+
logger.info(f"Signed contract {contract_id} by {request.signer_id}")
|
| 221 |
+
return updated_contract
|
| 222 |
+
|
| 223 |
+
except Exception as e:
|
| 224 |
+
logger.error(f"Failed to sign contract {contract_id}: {e}")
|
| 225 |
+
raise
|
| 226 |
+
|
| 227 |
+
async def revoke_contract(self, contract_id: UUID, request: ContractRevokeRequest) -> Optional[Contract]:
|
| 228 |
+
"""Revoke a contract.
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
contract_id: Contract UUID
|
| 232 |
+
request: Revocation request
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
Updated contract instance or None if not found
|
| 236 |
+
|
| 237 |
+
Raises:
|
| 238 |
+
ValueError: If revocation fails
|
| 239 |
+
"""
|
| 240 |
+
try:
|
| 241 |
+
registry = self.get_registry()
|
| 242 |
+
|
| 243 |
+
# Validate contract exists and can be revoked
|
| 244 |
+
contract = registry.get_contract(contract_id)
|
| 245 |
+
if not contract:
|
| 246 |
+
raise ValueError("Contract not found")
|
| 247 |
+
|
| 248 |
+
if contract.state == ContractState.REVOKED:
|
| 249 |
+
raise ValueError("Contract is already revoked")
|
| 250 |
+
|
| 251 |
+
if contract.state == ContractState.EXPIRED:
|
| 252 |
+
raise ValueError("Cannot revoke expired contract")
|
| 253 |
+
|
| 254 |
+
# Revoke contract
|
| 255 |
+
updated_contract = registry.revoke_contract(
|
| 256 |
+
contract_id, request.reason, request.revoked_by, request.metadata
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
logger.info(f"Revoked contract {contract_id} by {request.revoked_by}: {request.reason}")
|
| 260 |
+
return updated_contract
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
logger.error(f"Failed to revoke contract {contract_id}: {e}")
|
| 264 |
+
raise
|
| 265 |
+
|
| 266 |
+
def _verify_contract_signature(self, contract: Contract, request: ContractSignRequest) -> bool:
|
| 267 |
+
"""Verify a contract signature.
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
contract: Contract instance
|
| 271 |
+
request: Signing request
|
| 272 |
+
|
| 273 |
+
Returns:
|
| 274 |
+
True if signature is valid, False otherwise
|
| 275 |
+
"""
|
| 276 |
+
try:
|
| 277 |
+
from .crypto import verify_signature
|
| 278 |
+
|
| 279 |
+
# Create signing message
|
| 280 |
+
signing_message = f"{contract.id}:{contract.get_content_hash()}:{request.signer_id}:{request.signer_type}"
|
| 281 |
+
|
| 282 |
+
# Verify signature
|
| 283 |
+
return verify_signature(
|
| 284 |
+
request.public_key,
|
| 285 |
+
signing_message,
|
| 286 |
+
request.signature
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logger.error(f"Signature verification failed: {e}")
|
| 291 |
+
return False
|
| 292 |
+
|
| 293 |
+
async def get_contracts_by_party(self, party_id: str) -> List[Contract]:
|
| 294 |
+
"""Get contracts involving a specific party.
|
| 295 |
+
|
| 296 |
+
Args:
|
| 297 |
+
party_id: Party ID to search for
|
| 298 |
+
|
| 299 |
+
Returns:
|
| 300 |
+
List of contracts involving the party
|
| 301 |
+
"""
|
| 302 |
+
try:
|
| 303 |
+
registry = self.get_registry()
|
| 304 |
+
return registry.get_contracts_by_party(party_id)
|
| 305 |
+
except Exception as e:
|
| 306 |
+
logger.error(f"Failed to get contracts for party {party_id}: {e}")
|
| 307 |
+
return []
|
| 308 |
+
|
| 309 |
+
async def cleanup_expired_contracts(self) -> int:
|
| 310 |
+
"""Mark expired contracts as expired.
|
| 311 |
+
|
| 312 |
+
Returns:
|
| 313 |
+
Number of contracts marked as expired
|
| 314 |
+
"""
|
| 315 |
+
try:
|
| 316 |
+
registry = self.get_registry()
|
| 317 |
+
count = registry.mark_contracts_expired()
|
| 318 |
+
logger.info(f"Marked {count} contracts as expired")
|
| 319 |
+
return count
|
| 320 |
+
except Exception as e:
|
| 321 |
+
logger.error(f"Failed to cleanup expired contracts: {e}")
|
| 322 |
+
return 0
|
| 323 |
+
|
| 324 |
+
async def get_contract_statistics(self) -> dict:
|
| 325 |
+
"""Get contract statistics.
|
| 326 |
+
|
| 327 |
+
Returns:
|
| 328 |
+
Dictionary with contract statistics
|
| 329 |
+
"""
|
| 330 |
+
try:
|
| 331 |
+
registry = self.get_registry()
|
| 332 |
+
|
| 333 |
+
# Get all contracts
|
| 334 |
+
all_contracts = registry.list_contracts()
|
| 335 |
+
|
| 336 |
+
# Count by state
|
| 337 |
+
state_counts = {}
|
| 338 |
+
for state in ContractState:
|
| 339 |
+
state_counts[state.value] = len([c for c in all_contracts if c.state == state])
|
| 340 |
+
|
| 341 |
+
# Count HIPAA contracts
|
| 342 |
+
hipaa_count = len([c for c in all_contracts if c.is_hipaa_compliant])
|
| 343 |
+
|
| 344 |
+
# Count signed contracts
|
| 345 |
+
signed_count = len([c for c in all_contracts if c.state == ContractState.SIGNED])
|
| 346 |
+
|
| 347 |
+
return {
|
| 348 |
+
"total_contracts": len(all_contracts),
|
| 349 |
+
"by_state": state_counts,
|
| 350 |
+
"hipaa_compliant": hipaa_count,
|
| 351 |
+
"signed_contracts": signed_count,
|
| 352 |
+
"expired_contracts": len(registry.get_expired_contracts())
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
except Exception as e:
|
| 356 |
+
logger.error(f"Failed to get contract statistics: {e}")
|
| 357 |
+
return {}
|
src/fastmcp/contracts/registry.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contract registry for managing contract persistence and lifecycle."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
from uuid import UUID
|
| 7 |
+
|
| 8 |
+
from sqlmodel import Session, select
|
| 9 |
+
|
| 10 |
+
from fastmcp.utilities.logging import get_logger
|
| 11 |
+
|
| 12 |
+
from .contract import Contract, ContractState, Signature
|
| 13 |
+
from .crypto import ContractSigner, CryptoError
|
| 14 |
+
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ContractRegistry:
|
| 19 |
+
"""Registry for managing contract persistence and lifecycle operations."""
|
| 20 |
+
|
| 21 |
+
def __init__(self, session: Session):
|
| 22 |
+
"""Initialize registry with database session.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
session: SQLModel database session
|
| 26 |
+
"""
|
| 27 |
+
self.session = session
|
| 28 |
+
|
| 29 |
+
def create_contract(self, contract_data: dict, created_by: str) -> Contract:
|
| 30 |
+
"""Create a new contract.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
contract_data: Contract data dictionary
|
| 34 |
+
created_by: ID of the creating party
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
Created contract instance
|
| 38 |
+
|
| 39 |
+
Raises:
|
| 40 |
+
ValueError: If contract data is invalid
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
# Create contract instance
|
| 44 |
+
contract = Contract(
|
| 45 |
+
title=contract_data["title"],
|
| 46 |
+
description=contract_data["description"],
|
| 47 |
+
created_by=created_by,
|
| 48 |
+
is_hipaa_compliant=contract_data.get("is_hipaa_compliant", False),
|
| 49 |
+
version=contract_data.get("version", "1.0.0")
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
# Set clauses
|
| 53 |
+
if "clauses" in contract_data:
|
| 54 |
+
from .contract import Clause
|
| 55 |
+
clauses = [Clause(**clause) for clause in contract_data["clauses"]]
|
| 56 |
+
contract.set_clauses(clauses)
|
| 57 |
+
|
| 58 |
+
# Set parties
|
| 59 |
+
if "parties" in contract_data:
|
| 60 |
+
contract.set_parties(contract_data["parties"])
|
| 61 |
+
|
| 62 |
+
# Set HIPAA entities
|
| 63 |
+
if "hipaa_entities" in contract_data and contract_data["hipaa_entities"]:
|
| 64 |
+
contract.set_hipaa_entities(contract_data["hipaa_entities"])
|
| 65 |
+
|
| 66 |
+
# Set metadata
|
| 67 |
+
if "metadata" in contract_data:
|
| 68 |
+
contract.set_metadata(contract_data["metadata"])
|
| 69 |
+
|
| 70 |
+
# Set expiration
|
| 71 |
+
if "expires_at" in contract_data and contract_data["expires_at"]:
|
| 72 |
+
contract.expires_at = contract_data["expires_at"]
|
| 73 |
+
|
| 74 |
+
# Save to database
|
| 75 |
+
self.session.add(contract)
|
| 76 |
+
self.session.commit()
|
| 77 |
+
self.session.refresh(contract)
|
| 78 |
+
|
| 79 |
+
logger.info(f"Created contract {contract.id} by {created_by}")
|
| 80 |
+
return contract
|
| 81 |
+
|
| 82 |
+
except Exception as e:
|
| 83 |
+
self.session.rollback()
|
| 84 |
+
logger.error(f"Failed to create contract: {e}")
|
| 85 |
+
raise ValueError(f"Contract creation failed: {e}")
|
| 86 |
+
|
| 87 |
+
def get_contract(self, contract_id: UUID) -> Optional[Contract]:
|
| 88 |
+
"""Get a contract by ID.
|
| 89 |
+
|
| 90 |
+
Args:
|
| 91 |
+
contract_id: Contract UUID
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
Contract instance or None if not found
|
| 95 |
+
"""
|
| 96 |
+
try:
|
| 97 |
+
statement = select(Contract).where(Contract.id == contract_id)
|
| 98 |
+
return self.session.exec(statement).first()
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"Failed to get contract {contract_id}: {e}")
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
def list_contracts(self, state: Optional[ContractState] = None,
|
| 104 |
+
created_by: Optional[str] = None) -> List[Contract]:
|
| 105 |
+
"""List contracts with optional filtering.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
state: Optional state filter
|
| 109 |
+
created_by: Optional creator filter
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
List of contract instances
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
statement = select(Contract)
|
| 116 |
+
|
| 117 |
+
if state is not None:
|
| 118 |
+
statement = statement.where(Contract.state == state)
|
| 119 |
+
|
| 120 |
+
if created_by is not None:
|
| 121 |
+
statement = statement.where(Contract.created_by == created_by)
|
| 122 |
+
|
| 123 |
+
statement = statement.order_by(Contract.created_at.desc())
|
| 124 |
+
return list(self.session.exec(statement))
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.error(f"Failed to list contracts: {e}")
|
| 128 |
+
return []
|
| 129 |
+
|
| 130 |
+
def update_contract_state(self, contract_id: UUID, new_state: ContractState,
|
| 131 |
+
updated_by: str, metadata: Optional[dict] = None) -> Optional[Contract]:
|
| 132 |
+
"""Update contract state with lifecycle validation.
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
contract_id: Contract UUID
|
| 136 |
+
new_state: New state to transition to
|
| 137 |
+
updated_by: ID of the updating party
|
| 138 |
+
metadata: Optional metadata for the state change
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
Updated contract instance or None if not found
|
| 142 |
+
|
| 143 |
+
Raises:
|
| 144 |
+
ValueError: If state transition is invalid
|
| 145 |
+
"""
|
| 146 |
+
try:
|
| 147 |
+
contract = self.get_contract(contract_id)
|
| 148 |
+
if not contract:
|
| 149 |
+
return None
|
| 150 |
+
|
| 151 |
+
# Validate state transition
|
| 152 |
+
if not contract.can_transition_to(new_state):
|
| 153 |
+
raise ValueError(f"Invalid state transition from {contract.state} to {new_state}")
|
| 154 |
+
|
| 155 |
+
# Update state and timestamps
|
| 156 |
+
old_state = contract.state
|
| 157 |
+
contract.state = new_state
|
| 158 |
+
contract.last_modified = datetime.utcnow()
|
| 159 |
+
|
| 160 |
+
# Set state-specific timestamps
|
| 161 |
+
if new_state == ContractState.PROPOSED and contract.proposed_at is None:
|
| 162 |
+
contract.proposed_at = datetime.utcnow()
|
| 163 |
+
elif new_state == ContractState.SIGNED and contract.signed_at is None:
|
| 164 |
+
contract.signed_at = datetime.utcnow()
|
| 165 |
+
elif new_state == ContractState.REVOKED and contract.revoked_at is None:
|
| 166 |
+
contract.revoked_at = datetime.utcnow()
|
| 167 |
+
|
| 168 |
+
# Update metadata if provided
|
| 169 |
+
if metadata:
|
| 170 |
+
current_metadata = contract.get_metadata()
|
| 171 |
+
current_metadata.update(metadata)
|
| 172 |
+
current_metadata[f"state_change_{new_state}"] = {
|
| 173 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 174 |
+
"updated_by": updated_by,
|
| 175 |
+
"previous_state": old_state
|
| 176 |
+
}
|
| 177 |
+
contract.set_metadata(current_metadata)
|
| 178 |
+
|
| 179 |
+
# Save changes
|
| 180 |
+
self.session.add(contract)
|
| 181 |
+
self.session.commit()
|
| 182 |
+
self.session.refresh(contract)
|
| 183 |
+
|
| 184 |
+
logger.info(f"Updated contract {contract_id} state from {old_state} to {new_state} by {updated_by}")
|
| 185 |
+
return contract
|
| 186 |
+
|
| 187 |
+
except Exception as e:
|
| 188 |
+
self.session.rollback()
|
| 189 |
+
logger.error(f"Failed to update contract {contract_id} state: {e}")
|
| 190 |
+
raise
|
| 191 |
+
|
| 192 |
+
def add_signature(self, contract_id: UUID, signature_data: dict) -> Optional[Contract]:
|
| 193 |
+
"""Add a signature to a contract.
|
| 194 |
+
|
| 195 |
+
Args:
|
| 196 |
+
contract_id: Contract UUID
|
| 197 |
+
signature_data: Signature data dictionary
|
| 198 |
+
|
| 199 |
+
Returns:
|
| 200 |
+
Updated contract instance or None if not found
|
| 201 |
+
|
| 202 |
+
Raises:
|
| 203 |
+
ValueError: If signature is invalid or contract not in signable state
|
| 204 |
+
"""
|
| 205 |
+
try:
|
| 206 |
+
contract = self.get_contract(contract_id)
|
| 207 |
+
if not contract:
|
| 208 |
+
return None
|
| 209 |
+
|
| 210 |
+
# Validate contract state
|
| 211 |
+
if contract.state not in [ContractState.PROPOSED, ContractState.SIGNED]:
|
| 212 |
+
raise ValueError(f"Cannot sign contract in state {contract.state}")
|
| 213 |
+
|
| 214 |
+
# Create signature
|
| 215 |
+
from .contract import Signature
|
| 216 |
+
signature = Signature(**signature_data)
|
| 217 |
+
|
| 218 |
+
# Verify signature
|
| 219 |
+
if not self._verify_signature(contract, signature):
|
| 220 |
+
raise ValueError("Invalid signature")
|
| 221 |
+
|
| 222 |
+
# Add signature
|
| 223 |
+
signatures = contract.get_signatures()
|
| 224 |
+
signatures.append(signature)
|
| 225 |
+
contract.set_signatures(signatures)
|
| 226 |
+
|
| 227 |
+
# Update state if fully signed
|
| 228 |
+
if contract.is_fully_signed() and contract.state == ContractState.PROPOSED:
|
| 229 |
+
# Use the proper state transition method
|
| 230 |
+
contract = self.update_contract_state(contract_id, ContractState.SIGNED, signature.signer_id)
|
| 231 |
+
|
| 232 |
+
contract.last_modified = datetime.utcnow()
|
| 233 |
+
|
| 234 |
+
# Save changes
|
| 235 |
+
self.session.add(contract)
|
| 236 |
+
self.session.commit()
|
| 237 |
+
self.session.refresh(contract)
|
| 238 |
+
|
| 239 |
+
logger.info(f"Added signature to contract {contract_id} by {signature.signer_id}")
|
| 240 |
+
return contract
|
| 241 |
+
|
| 242 |
+
except Exception as e:
|
| 243 |
+
self.session.rollback()
|
| 244 |
+
logger.error(f"Failed to add signature to contract {contract_id}: {e}")
|
| 245 |
+
raise
|
| 246 |
+
|
| 247 |
+
def _verify_signature(self, contract: Contract, signature: Signature) -> bool:
|
| 248 |
+
"""Verify a contract signature.
|
| 249 |
+
|
| 250 |
+
Args:
|
| 251 |
+
contract: Contract instance
|
| 252 |
+
signature: Signature to verify
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
True if signature is valid, False otherwise
|
| 256 |
+
"""
|
| 257 |
+
try:
|
| 258 |
+
from .crypto import verify_signature
|
| 259 |
+
|
| 260 |
+
# Create signing message
|
| 261 |
+
signing_message = f"{contract.id}:{contract.get_content_hash()}:{signature.signer_id}:{signature.signer_type}"
|
| 262 |
+
|
| 263 |
+
# Verify signature
|
| 264 |
+
return verify_signature(
|
| 265 |
+
signature.public_key,
|
| 266 |
+
signing_message,
|
| 267 |
+
signature.signature
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.error(f"Signature verification failed: {e}")
|
| 272 |
+
return False
|
| 273 |
+
|
| 274 |
+
def revoke_contract(self, contract_id: UUID, reason: str, revoked_by: str,
|
| 275 |
+
metadata: Optional[dict] = None) -> Optional[Contract]:
|
| 276 |
+
"""Revoke a contract.
|
| 277 |
+
|
| 278 |
+
Args:
|
| 279 |
+
contract_id: Contract UUID
|
| 280 |
+
reason: Reason for revocation
|
| 281 |
+
revoked_by: ID of the revoking party
|
| 282 |
+
metadata: Optional revocation metadata
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
Updated contract instance or None if not found
|
| 286 |
+
|
| 287 |
+
Raises:
|
| 288 |
+
ValueError: If contract cannot be revoked
|
| 289 |
+
"""
|
| 290 |
+
try:
|
| 291 |
+
contract = self.get_contract(contract_id)
|
| 292 |
+
if not contract:
|
| 293 |
+
return None
|
| 294 |
+
|
| 295 |
+
# Validate revocation
|
| 296 |
+
if contract.state == ContractState.REVOKED:
|
| 297 |
+
raise ValueError("Contract is already revoked")
|
| 298 |
+
|
| 299 |
+
if contract.state == ContractState.EXPIRED:
|
| 300 |
+
raise ValueError("Cannot revoke expired contract")
|
| 301 |
+
|
| 302 |
+
# Update metadata with revocation info
|
| 303 |
+
current_metadata = contract.get_metadata()
|
| 304 |
+
current_metadata["revocation"] = {
|
| 305 |
+
"reason": reason,
|
| 306 |
+
"revoked_by": revoked_by,
|
| 307 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 308 |
+
}
|
| 309 |
+
if metadata:
|
| 310 |
+
current_metadata["revocation"].update(metadata)
|
| 311 |
+
|
| 312 |
+
contract.set_metadata(current_metadata)
|
| 313 |
+
|
| 314 |
+
# Update state
|
| 315 |
+
return self.update_contract_state(contract_id, ContractState.REVOKED, revoked_by)
|
| 316 |
+
|
| 317 |
+
except Exception as e:
|
| 318 |
+
logger.error(f"Failed to revoke contract {contract_id}: {e}")
|
| 319 |
+
raise
|
| 320 |
+
|
| 321 |
+
def get_contracts_by_party(self, party_id: str) -> List[Contract]:
|
| 322 |
+
"""Get contracts involving a specific party.
|
| 323 |
+
|
| 324 |
+
Args:
|
| 325 |
+
party_id: Party ID to search for
|
| 326 |
+
|
| 327 |
+
Returns:
|
| 328 |
+
List of contracts involving the party
|
| 329 |
+
"""
|
| 330 |
+
try:
|
| 331 |
+
statement = select(Contract)
|
| 332 |
+
contracts = list(self.session.exec(statement))
|
| 333 |
+
|
| 334 |
+
# Filter contracts that involve the party
|
| 335 |
+
party_contracts = []
|
| 336 |
+
for contract in contracts:
|
| 337 |
+
parties = contract.get_parties()
|
| 338 |
+
if any(party.get("id") == party_id for party in parties):
|
| 339 |
+
party_contracts.append(contract)
|
| 340 |
+
|
| 341 |
+
return party_contracts
|
| 342 |
+
|
| 343 |
+
except Exception as e:
|
| 344 |
+
logger.error(f"Failed to get contracts for party {party_id}: {e}")
|
| 345 |
+
return []
|
| 346 |
+
|
| 347 |
+
def get_expired_contracts(self) -> List[Contract]:
|
| 348 |
+
"""Get contracts that have expired.
|
| 349 |
+
|
| 350 |
+
Returns:
|
| 351 |
+
List of expired contracts
|
| 352 |
+
"""
|
| 353 |
+
try:
|
| 354 |
+
now = datetime.utcnow()
|
| 355 |
+
statement = select(Contract).where(
|
| 356 |
+
Contract.expires_at.isnot(None),
|
| 357 |
+
Contract.expires_at < now,
|
| 358 |
+
Contract.state.notin_([ContractState.EXPIRED, ContractState.REVOKED])
|
| 359 |
+
)
|
| 360 |
+
return list(self.session.exec(statement))
|
| 361 |
+
|
| 362 |
+
except Exception as e:
|
| 363 |
+
logger.error(f"Failed to get expired contracts: {e}")
|
| 364 |
+
return []
|
| 365 |
+
|
| 366 |
+
def mark_contracts_expired(self) -> int:
|
| 367 |
+
"""Mark expired contracts as expired.
|
| 368 |
+
|
| 369 |
+
Returns:
|
| 370 |
+
Number of contracts marked as expired
|
| 371 |
+
"""
|
| 372 |
+
try:
|
| 373 |
+
expired_contracts = self.get_expired_contracts()
|
| 374 |
+
count = 0
|
| 375 |
+
|
| 376 |
+
for contract in expired_contracts:
|
| 377 |
+
contract.state = ContractState.EXPIRED
|
| 378 |
+
contract.last_modified = datetime.utcnow()
|
| 379 |
+
self.session.add(contract)
|
| 380 |
+
count += 1
|
| 381 |
+
|
| 382 |
+
if count > 0:
|
| 383 |
+
self.session.commit()
|
| 384 |
+
logger.info(f"Marked {count} contracts as expired")
|
| 385 |
+
|
| 386 |
+
return count
|
| 387 |
+
|
| 388 |
+
except Exception as e:
|
| 389 |
+
self.session.rollback()
|
| 390 |
+
logger.error(f"Failed to mark contracts as expired: {e}")
|
| 391 |
+
return 0
|
src/fastmcp/server/contract_routes.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contract management HTTP routes."""
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
from uuid import UUID
|
| 5 |
+
|
| 6 |
+
from starlette.requests import Request
|
| 7 |
+
from starlette.responses import JSONResponse
|
| 8 |
+
from starlette.routing import Route
|
| 9 |
+
|
| 10 |
+
from fastmcp.contracts import ContractEngine, ContractState
|
| 11 |
+
from fastmcp.contracts.contract import (
|
| 12 |
+
ContractCreateRequest, ContractProposeRequest, ContractSignRequest,
|
| 13 |
+
ContractRevokeRequest, ContractResponse
|
| 14 |
+
)
|
| 15 |
+
from fastmcp.utilities.logging import get_logger
|
| 16 |
+
|
| 17 |
+
logger = get_logger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
async def create_contract_endpoint(request: Request) -> JSONResponse:
|
| 21 |
+
"""HTTP endpoint for creating contracts.
|
| 22 |
+
|
| 23 |
+
Expected JSON body:
|
| 24 |
+
{
|
| 25 |
+
"title": "Contract Title",
|
| 26 |
+
"description": "Contract Description",
|
| 27 |
+
"clauses": [...],
|
| 28 |
+
"parties": [...],
|
| 29 |
+
"is_hipaa_compliant": false,
|
| 30 |
+
"expires_at": "2024-12-31T23:59:59Z"
|
| 31 |
+
}
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
# Parse request body
|
| 35 |
+
body = await request.json()
|
| 36 |
+
|
| 37 |
+
# Get contract engine from request state
|
| 38 |
+
contract_engine: ContractEngine = request.app.state.contract_engine
|
| 39 |
+
|
| 40 |
+
# Get creator from headers or body
|
| 41 |
+
created_by = request.headers.get("X-User-ID") or body.get("created_by", "anonymous")
|
| 42 |
+
|
| 43 |
+
# Create contract request
|
| 44 |
+
contract_request = ContractCreateRequest(**body)
|
| 45 |
+
|
| 46 |
+
# Create contract
|
| 47 |
+
contract = await contract_engine.create_contract(contract_request, created_by)
|
| 48 |
+
|
| 49 |
+
# Return contract response
|
| 50 |
+
response = ContractResponse.from_contract(contract)
|
| 51 |
+
return JSONResponse(
|
| 52 |
+
status_code=201,
|
| 53 |
+
content=response.model_dump()
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"Contract creation error: {e}")
|
| 58 |
+
return JSONResponse(
|
| 59 |
+
status_code=400,
|
| 60 |
+
content={
|
| 61 |
+
"error": "Contract creation failed",
|
| 62 |
+
"reason": str(e)
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def get_contract_endpoint(request: Request) -> JSONResponse:
|
| 68 |
+
"""HTTP endpoint for getting a contract by ID."""
|
| 69 |
+
try:
|
| 70 |
+
# Get contract ID from path
|
| 71 |
+
contract_id = UUID(request.path_params["id"])
|
| 72 |
+
|
| 73 |
+
# Get contract engine from request state
|
| 74 |
+
contract_engine: ContractEngine = request.app.state.contract_engine
|
| 75 |
+
|
| 76 |
+
# Get contract
|
| 77 |
+
contract = await contract_engine.get_contract(contract_id)
|
| 78 |
+
|
| 79 |
+
if not contract:
|
| 80 |
+
return JSONResponse(
|
| 81 |
+
status_code=404,
|
| 82 |
+
content={
|
| 83 |
+
"error": "Contract not found",
|
| 84 |
+
"contract_id": str(contract_id)
|
| 85 |
+
}
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Return contract response
|
| 89 |
+
response = ContractResponse.from_contract(contract)
|
| 90 |
+
return JSONResponse(
|
| 91 |
+
status_code=200,
|
| 92 |
+
content=response.model_dump()
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
except ValueError as e:
|
| 96 |
+
return JSONResponse(
|
| 97 |
+
status_code=400,
|
| 98 |
+
content={
|
| 99 |
+
"error": "Invalid contract ID",
|
| 100 |
+
"reason": str(e)
|
| 101 |
+
}
|
| 102 |
+
)
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.error(f"Contract retrieval error: {e}")
|
| 105 |
+
return JSONResponse(
|
| 106 |
+
status_code=500,
|
| 107 |
+
content={
|
| 108 |
+
"error": "Contract retrieval failed",
|
| 109 |
+
"reason": str(e)
|
| 110 |
+
}
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def list_contracts_endpoint(request: Request) -> JSONResponse:
|
| 115 |
+
"""HTTP endpoint for listing contracts."""
|
| 116 |
+
try:
|
| 117 |
+
# Get query parameters
|
| 118 |
+
state = request.query_params.get("state")
|
| 119 |
+
created_by = request.query_params.get("created_by")
|
| 120 |
+
|
| 121 |
+
# Get contract engine from request state
|
| 122 |
+
contract_engine: ContractEngine = request.app.state.contract_engine
|
| 123 |
+
|
| 124 |
+
# Parse state if provided
|
| 125 |
+
contract_state = None
|
| 126 |
+
if state:
|
| 127 |
+
try:
|
| 128 |
+
contract_state = ContractState(state)
|
| 129 |
+
except ValueError:
|
| 130 |
+
return JSONResponse(
|
| 131 |
+
status_code=400,
|
| 132 |
+
content={
|
| 133 |
+
"error": "Invalid state",
|
| 134 |
+
"valid_states": [s.value for s in ContractState]
|
| 135 |
+
}
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
# List contracts
|
| 139 |
+
contracts = await contract_engine.list_contracts(contract_state, created_by)
|
| 140 |
+
|
| 141 |
+
# Convert to response format
|
| 142 |
+
responses = [ContractResponse.from_contract(contract).model_dump() for contract in contracts]
|
| 143 |
+
|
| 144 |
+
return JSONResponse(
|
| 145 |
+
status_code=200,
|
| 146 |
+
content={
|
| 147 |
+
"contracts": responses,
|
| 148 |
+
"count": len(responses)
|
| 149 |
+
}
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.error(f"Contract listing error: {e}")
|
| 154 |
+
return JSONResponse(
|
| 155 |
+
status_code=500,
|
| 156 |
+
content={
|
| 157 |
+
"error": "Contract listing failed",
|
| 158 |
+
"reason": str(e)
|
| 159 |
+
}
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
async def propose_contract_endpoint(request: Request) -> JSONResponse:
|
| 164 |
+
"""HTTP endpoint for proposing contracts.
|
| 165 |
+
|
| 166 |
+
Expected JSON body:
|
| 167 |
+
{
|
| 168 |
+
"proposed_to": ["party1", "party2"],
|
| 169 |
+
"message": "Please review and sign this contract"
|
| 170 |
+
}
|
| 171 |
+
"""
|
| 172 |
+
try:
|
| 173 |
+
# Get contract ID from path
|
| 174 |
+
contract_id = UUID(request.path_params["id"])
|
| 175 |
+
|
| 176 |
+
# Parse request body
|
| 177 |
+
body = await request.json()
|
| 178 |
+
|
| 179 |
+
# Get contract engine from request state
|
| 180 |
+
contract_engine: ContractEngine = request.app.state.contract_engine
|
| 181 |
+
|
| 182 |
+
# Get proposer from headers or body
|
| 183 |
+
proposed_by = request.headers.get("X-User-ID") or body.get("proposed_by", "anonymous")
|
| 184 |
+
|
| 185 |
+
# Create proposal request
|
| 186 |
+
proposal_request = ContractProposeRequest(**body)
|
| 187 |
+
|
| 188 |
+
# Propose contract
|
| 189 |
+
contract = await contract_engine.propose_contract(contract_id, proposal_request, proposed_by)
|
| 190 |
+
|
| 191 |
+
if not contract:
|
| 192 |
+
return JSONResponse(
|
| 193 |
+
status_code=404,
|
| 194 |
+
content={
|
| 195 |
+
"error": "Contract not found",
|
| 196 |
+
"contract_id": str(contract_id)
|
| 197 |
+
}
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# Return contract response
|
| 201 |
+
response = ContractResponse.from_contract(contract)
|
| 202 |
+
return JSONResponse(
|
| 203 |
+
status_code=200,
|
| 204 |
+
content=response.model_dump()
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
except ValueError as e:
|
| 208 |
+
return JSONResponse(
|
| 209 |
+
status_code=400,
|
| 210 |
+
content={
|
| 211 |
+
"error": "Invalid request",
|
| 212 |
+
"reason": str(e)
|
| 213 |
+
}
|
| 214 |
+
)
|
| 215 |
+
except Exception as e:
|
| 216 |
+
logger.error(f"Contract proposal error: {e}")
|
| 217 |
+
return JSONResponse(
|
| 218 |
+
status_code=500,
|
| 219 |
+
content={
|
| 220 |
+
"error": "Contract proposal failed",
|
| 221 |
+
"reason": str(e)
|
| 222 |
+
}
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
async def sign_contract_endpoint(request: Request) -> JSONResponse:
|
| 227 |
+
"""HTTP endpoint for signing contracts.
|
| 228 |
+
|
| 229 |
+
Expected JSON body:
|
| 230 |
+
{
|
| 231 |
+
"signer_id": "party1",
|
| 232 |
+
"signer_type": "provider",
|
| 233 |
+
"public_key": "base64_public_key",
|
| 234 |
+
"signature": "base64_signature"
|
| 235 |
+
}
|
| 236 |
+
"""
|
| 237 |
+
try:
|
| 238 |
+
# Get contract ID from path
|
| 239 |
+
contract_id = UUID(request.path_params["id"])
|
| 240 |
+
|
| 241 |
+
# Parse request body
|
| 242 |
+
body = await request.json()
|
| 243 |
+
|
| 244 |
+
# Get contract engine from request state
|
| 245 |
+
contract_engine: ContractEngine = request.app.state.contract_engine
|
| 246 |
+
|
| 247 |
+
# Create signing request
|
| 248 |
+
sign_request = ContractSignRequest(**body)
|
| 249 |
+
|
| 250 |
+
# Sign contract
|
| 251 |
+
contract = await contract_engine.sign_contract(contract_id, sign_request)
|
| 252 |
+
|
| 253 |
+
if not contract:
|
| 254 |
+
return JSONResponse(
|
| 255 |
+
status_code=404,
|
| 256 |
+
content={
|
| 257 |
+
"error": "Contract not found",
|
| 258 |
+
"contract_id": str(contract_id)
|
| 259 |
+
}
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# Return contract response
|
| 263 |
+
response = ContractResponse.from_contract(contract)
|
| 264 |
+
return JSONResponse(
|
| 265 |
+
status_code=200,
|
| 266 |
+
content=response.model_dump()
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
except ValueError as e:
|
| 270 |
+
return JSONResponse(
|
| 271 |
+
status_code=400,
|
| 272 |
+
content={
|
| 273 |
+
"error": "Invalid request",
|
| 274 |
+
"reason": str(e)
|
| 275 |
+
}
|
| 276 |
+
)
|
| 277 |
+
except Exception as e:
|
| 278 |
+
logger.error(f"Contract signing error: {e}")
|
| 279 |
+
return JSONResponse(
|
| 280 |
+
status_code=500,
|
| 281 |
+
content={
|
| 282 |
+
"error": "Contract signing failed",
|
| 283 |
+
"reason": str(e)
|
| 284 |
+
}
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
async def revoke_contract_endpoint(request: Request) -> JSONResponse:
|
| 289 |
+
"""HTTP endpoint for revoking contracts.
|
| 290 |
+
|
| 291 |
+
Expected JSON body:
|
| 292 |
+
{
|
| 293 |
+
"reason": "Contract terms violated",
|
| 294 |
+
"revoked_by": "party1"
|
| 295 |
+
}
|
| 296 |
+
"""
|
| 297 |
+
try:
|
| 298 |
+
# Get contract ID from path
|
| 299 |
+
contract_id = UUID(request.path_params["id"])
|
| 300 |
+
|
| 301 |
+
# Parse request body
|
| 302 |
+
body = await request.json()
|
| 303 |
+
|
| 304 |
+
# Get contract engine from request state
|
| 305 |
+
contract_engine: ContractEngine = request.app.state.contract_engine
|
| 306 |
+
|
| 307 |
+
# Create revocation request
|
| 308 |
+
revoke_request = ContractRevokeRequest(**body)
|
| 309 |
+
|
| 310 |
+
# Revoke contract
|
| 311 |
+
contract = await contract_engine.revoke_contract(contract_id, revoke_request)
|
| 312 |
+
|
| 313 |
+
if not contract:
|
| 314 |
+
return JSONResponse(
|
| 315 |
+
status_code=404,
|
| 316 |
+
content={
|
| 317 |
+
"error": "Contract not found",
|
| 318 |
+
"contract_id": str(contract_id)
|
| 319 |
+
}
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
# Return contract response
|
| 323 |
+
response = ContractResponse.from_contract(contract)
|
| 324 |
+
return JSONResponse(
|
| 325 |
+
status_code=200,
|
| 326 |
+
content=response.model_dump()
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
except ValueError as e:
|
| 330 |
+
return JSONResponse(
|
| 331 |
+
status_code=400,
|
| 332 |
+
content={
|
| 333 |
+
"error": "Invalid request",
|
| 334 |
+
"reason": str(e)
|
| 335 |
+
}
|
| 336 |
+
)
|
| 337 |
+
except Exception as e:
|
| 338 |
+
logger.error(f"Contract revocation error: {e}")
|
| 339 |
+
return JSONResponse(
|
| 340 |
+
status_code=500,
|
| 341 |
+
content={
|
| 342 |
+
"error": "Contract revocation failed",
|
| 343 |
+
"reason": str(e)
|
| 344 |
+
}
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
async def get_contract_statistics_endpoint(request: Request) -> JSONResponse:
|
| 349 |
+
"""HTTP endpoint for getting contract statistics."""
|
| 350 |
+
try:
|
| 351 |
+
# Get contract engine from request state
|
| 352 |
+
contract_engine: ContractEngine = request.app.state.contract_engine
|
| 353 |
+
|
| 354 |
+
# Get statistics
|
| 355 |
+
stats = await contract_engine.get_contract_statistics()
|
| 356 |
+
|
| 357 |
+
return JSONResponse(
|
| 358 |
+
status_code=200,
|
| 359 |
+
content=stats
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
except Exception as e:
|
| 363 |
+
logger.error(f"Contract statistics error: {e}")
|
| 364 |
+
return JSONResponse(
|
| 365 |
+
status_code=500,
|
| 366 |
+
content={
|
| 367 |
+
"error": "Contract statistics failed",
|
| 368 |
+
"reason": str(e)
|
| 369 |
+
}
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def create_contract_routes(contract_engine: ContractEngine) -> List[Route]:
|
| 374 |
+
"""Create contract management routes.
|
| 375 |
+
|
| 376 |
+
Args:
|
| 377 |
+
contract_engine: The contract engine instance
|
| 378 |
+
|
| 379 |
+
Returns:
|
| 380 |
+
List of Starlette Route objects for contract management
|
| 381 |
+
"""
|
| 382 |
+
def endpoint_with_engine(endpoint_func):
|
| 383 |
+
async def wrapper(request: Request) -> JSONResponse:
|
| 384 |
+
# Store contract engine in app state for access in endpoint
|
| 385 |
+
request.app.state.contract_engine = contract_engine
|
| 386 |
+
return await endpoint_func(request)
|
| 387 |
+
return wrapper
|
| 388 |
+
|
| 389 |
+
return [
|
| 390 |
+
Route(
|
| 391 |
+
path="/contracts",
|
| 392 |
+
endpoint=endpoint_with_engine(create_contract_endpoint),
|
| 393 |
+
methods=["POST"]
|
| 394 |
+
),
|
| 395 |
+
Route(
|
| 396 |
+
path="/contracts",
|
| 397 |
+
endpoint=endpoint_with_engine(list_contracts_endpoint),
|
| 398 |
+
methods=["GET"]
|
| 399 |
+
),
|
| 400 |
+
Route(
|
| 401 |
+
path="/contracts/statistics",
|
| 402 |
+
endpoint=endpoint_with_engine(get_contract_statistics_endpoint),
|
| 403 |
+
methods=["GET"]
|
| 404 |
+
),
|
| 405 |
+
Route(
|
| 406 |
+
path="/contracts/{id}",
|
| 407 |
+
endpoint=endpoint_with_engine(get_contract_endpoint),
|
| 408 |
+
methods=["GET"]
|
| 409 |
+
),
|
| 410 |
+
Route(
|
| 411 |
+
path="/contracts/{id}/propose",
|
| 412 |
+
endpoint=endpoint_with_engine(propose_contract_endpoint),
|
| 413 |
+
methods=["POST"]
|
| 414 |
+
),
|
| 415 |
+
Route(
|
| 416 |
+
path="/contracts/{id}/sign",
|
| 417 |
+
endpoint=endpoint_with_engine(sign_contract_endpoint),
|
| 418 |
+
methods=["POST"]
|
| 419 |
+
),
|
| 420 |
+
Route(
|
| 421 |
+
path="/contracts/{id}/revoke",
|
| 422 |
+
endpoint=endpoint_with_engine(revoke_contract_endpoint),
|
| 423 |
+
methods=["POST"]
|
| 424 |
+
)
|
| 425 |
+
]
|
src/fastmcp/server/server.py
CHANGED
|
@@ -65,6 +65,7 @@ from fastmcp.tools.tool import FunctionTool, Tool, ToolResult
|
|
| 65 |
from fastmcp.tools.tool_transform import ToolTransformConfig
|
| 66 |
from fastmcp.policy import PolicyEngine
|
| 67 |
from fastmcp.ledger import ProvenanceLedger
|
|
|
|
| 68 |
from fastmcp.utilities.cli import log_server_banner
|
| 69 |
from fastmcp.utilities.components import FastMCPComponent
|
| 70 |
from fastmcp.utilities.logging import get_logger
|
|
@@ -178,6 +179,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 178 |
self._mounted_servers: list[MountedServer] = []
|
| 179 |
self._policy_engine: Optional[PolicyEngine] = None
|
| 180 |
self._ledger: Optional[ProvenanceLedger] = None
|
|
|
|
| 181 |
self._tool_manager = ToolManager(
|
| 182 |
duplicate_behavior=on_duplicate_tools,
|
| 183 |
mask_error_details=mask_error_details,
|
|
@@ -522,6 +524,13 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 522 |
|
| 523 |
ledger_routes = create_ledger_routes(self._ledger)
|
| 524 |
routes.extend(ledger_routes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
|
| 526 |
# Recursively get routes from mounted servers
|
| 527 |
for mounted_server in self._mounted_servers:
|
|
@@ -579,6 +588,31 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 579 |
"""
|
| 580 |
return self._ledger
|
| 581 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
async def _mcp_list_tools(self) -> list[MCPTool]:
|
| 583 |
logger.debug("Handler called: list_tools")
|
| 584 |
|
|
|
|
| 65 |
from fastmcp.tools.tool_transform import ToolTransformConfig
|
| 66 |
from fastmcp.policy import PolicyEngine
|
| 67 |
from fastmcp.ledger import ProvenanceLedger
|
| 68 |
+
from fastmcp.contracts import ContractEngine
|
| 69 |
from fastmcp.utilities.cli import log_server_banner
|
| 70 |
from fastmcp.utilities.components import FastMCPComponent
|
| 71 |
from fastmcp.utilities.logging import get_logger
|
|
|
|
| 179 |
self._mounted_servers: list[MountedServer] = []
|
| 180 |
self._policy_engine: Optional[PolicyEngine] = None
|
| 181 |
self._ledger: Optional[ProvenanceLedger] = None
|
| 182 |
+
self._contract_engine: Optional[ContractEngine] = None
|
| 183 |
self._tool_manager = ToolManager(
|
| 184 |
duplicate_behavior=on_duplicate_tools,
|
| 185 |
mask_error_details=mask_error_details,
|
|
|
|
| 524 |
|
| 525 |
ledger_routes = create_ledger_routes(self._ledger)
|
| 526 |
routes.extend(ledger_routes)
|
| 527 |
+
|
| 528 |
+
# Add contract management endpoints if contract engine is configured
|
| 529 |
+
if self._contract_engine is not None:
|
| 530 |
+
from fastmcp.server.contract_routes import create_contract_routes
|
| 531 |
+
|
| 532 |
+
contract_routes = create_contract_routes(self._contract_engine)
|
| 533 |
+
routes.extend(contract_routes)
|
| 534 |
|
| 535 |
# Recursively get routes from mounted servers
|
| 536 |
for mounted_server in self._mounted_servers:
|
|
|
|
| 588 |
"""
|
| 589 |
return self._ledger
|
| 590 |
|
| 591 |
+
def enable_contract_engine(self, contract_engine: Optional[ContractEngine] = None, database_url: str = "sqlite:///contracts.db") -> ContractEngine:
|
| 592 |
+
"""Enable the contract engine for this server.
|
| 593 |
+
|
| 594 |
+
Args:
|
| 595 |
+
contract_engine: Optional contract engine instance. If None, creates a new one.
|
| 596 |
+
database_url: Database URL for contract persistence
|
| 597 |
+
|
| 598 |
+
Returns:
|
| 599 |
+
The contract engine instance
|
| 600 |
+
"""
|
| 601 |
+
if contract_engine is None:
|
| 602 |
+
contract_engine = ContractEngine(database_url)
|
| 603 |
+
|
| 604 |
+
self._contract_engine = contract_engine
|
| 605 |
+
logger.info("Contract engine enabled for server")
|
| 606 |
+
return contract_engine
|
| 607 |
+
|
| 608 |
+
def get_contract_engine(self) -> Optional[ContractEngine]:
|
| 609 |
+
"""Get the contract engine instance.
|
| 610 |
+
|
| 611 |
+
Returns:
|
| 612 |
+
The contract engine instance, or None if not enabled
|
| 613 |
+
"""
|
| 614 |
+
return self._contract_engine
|
| 615 |
+
|
| 616 |
async def _mcp_list_tools(self) -> list[MCPTool]:
|
| 617 |
logger.debug("Handler called: list_tools")
|
| 618 |
|
tests/contracts/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the contract management system."""
|
tests/contracts/test_contract_engine.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the contract engine."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from uuid import UUID
|
| 6 |
+
|
| 7 |
+
from fastmcp.contracts import ContractEngine, ContractState
|
| 8 |
+
from fastmcp.contracts.contract import (
|
| 9 |
+
Contract, Clause, Signature, ContractCreateRequest, ContractProposeRequest,
|
| 10 |
+
ContractSignRequest, ContractRevokeRequest
|
| 11 |
+
)
|
| 12 |
+
from fastmcp.contracts.crypto import Ed25519Signer, generate_key_pair
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TestContractEngine:
|
| 16 |
+
"""Test the contract engine functionality."""
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def contract_engine(self):
|
| 20 |
+
"""Create a contract engine for testing."""
|
| 21 |
+
return ContractEngine("sqlite:///:memory:")
|
| 22 |
+
|
| 23 |
+
@pytest.fixture
|
| 24 |
+
def sample_clauses(self):
|
| 25 |
+
"""Create sample clauses for testing."""
|
| 26 |
+
return [
|
| 27 |
+
Clause(
|
| 28 |
+
title="Data Handling",
|
| 29 |
+
content="All data must be handled in accordance with HIPAA regulations.",
|
| 30 |
+
type="hipaa"
|
| 31 |
+
),
|
| 32 |
+
Clause(
|
| 33 |
+
title="Access Control",
|
| 34 |
+
content="Only authorized personnel may access patient data.",
|
| 35 |
+
type="security"
|
| 36 |
+
)
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
@pytest.fixture
|
| 40 |
+
def sample_parties(self):
|
| 41 |
+
"""Create sample parties for testing."""
|
| 42 |
+
return [
|
| 43 |
+
{
|
| 44 |
+
"id": "provider1",
|
| 45 |
+
"name": "Healthcare Provider",
|
| 46 |
+
"type": "provider",
|
| 47 |
+
"email": "provider@example.com"
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"id": "patient1",
|
| 51 |
+
"name": "John Doe",
|
| 52 |
+
"type": "patient",
|
| 53 |
+
"email": "patient@example.com"
|
| 54 |
+
}
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
@pytest.fixture
|
| 58 |
+
def sample_contract_request(self, sample_clauses, sample_parties):
|
| 59 |
+
"""Create a sample contract creation request."""
|
| 60 |
+
return ContractCreateRequest(
|
| 61 |
+
title="HIPAA Data Sharing Agreement",
|
| 62 |
+
description="Agreement for sharing patient data between healthcare providers",
|
| 63 |
+
clauses=sample_clauses,
|
| 64 |
+
parties=sample_parties,
|
| 65 |
+
is_hipaa_compliant=True,
|
| 66 |
+
expires_at=datetime.utcnow() + timedelta(days=365)
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
def test_contract_engine_initialization(self, contract_engine):
|
| 70 |
+
"""Test contract engine initialization."""
|
| 71 |
+
assert contract_engine.engine is not None
|
| 72 |
+
assert contract_engine.get_registry() is not None
|
| 73 |
+
|
| 74 |
+
@pytest.mark.asyncio
|
| 75 |
+
async def test_create_contract(self, contract_engine, sample_contract_request):
|
| 76 |
+
"""Test contract creation."""
|
| 77 |
+
created_by = "admin"
|
| 78 |
+
|
| 79 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 80 |
+
|
| 81 |
+
assert contract is not None
|
| 82 |
+
assert contract.title == sample_contract_request.title
|
| 83 |
+
assert contract.description == sample_contract_request.description
|
| 84 |
+
assert contract.state == ContractState.DRAFT
|
| 85 |
+
assert contract.created_by == created_by
|
| 86 |
+
assert contract.is_hipaa_compliant is True
|
| 87 |
+
assert len(contract.get_clauses()) == 2
|
| 88 |
+
assert len(contract.get_parties()) == 2
|
| 89 |
+
|
| 90 |
+
@pytest.mark.asyncio
|
| 91 |
+
async def test_get_contract(self, contract_engine, sample_contract_request):
|
| 92 |
+
"""Test getting a contract by ID."""
|
| 93 |
+
created_by = "admin"
|
| 94 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 95 |
+
|
| 96 |
+
retrieved_contract = await contract_engine.get_contract(contract.id)
|
| 97 |
+
|
| 98 |
+
assert retrieved_contract is not None
|
| 99 |
+
assert retrieved_contract.id == contract.id
|
| 100 |
+
assert retrieved_contract.title == contract.title
|
| 101 |
+
|
| 102 |
+
@pytest.mark.asyncio
|
| 103 |
+
async def test_get_contract_not_found(self, contract_engine):
|
| 104 |
+
"""Test getting a non-existent contract."""
|
| 105 |
+
fake_id = UUID("12345678-1234-1234-1234-123456789012")
|
| 106 |
+
contract = await contract_engine.get_contract(fake_id)
|
| 107 |
+
|
| 108 |
+
assert contract is None
|
| 109 |
+
|
| 110 |
+
@pytest.mark.asyncio
|
| 111 |
+
async def test_list_contracts(self, contract_engine, sample_contract_request):
|
| 112 |
+
"""Test listing contracts."""
|
| 113 |
+
created_by = "admin"
|
| 114 |
+
|
| 115 |
+
# Create multiple contracts
|
| 116 |
+
contract1 = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 117 |
+
contract2 = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 118 |
+
|
| 119 |
+
# List all contracts
|
| 120 |
+
all_contracts = await contract_engine.list_contracts()
|
| 121 |
+
assert len(all_contracts) == 2
|
| 122 |
+
|
| 123 |
+
# List contracts by state
|
| 124 |
+
draft_contracts = await contract_engine.list_contracts(state=ContractState.DRAFT)
|
| 125 |
+
assert len(draft_contracts) == 2
|
| 126 |
+
|
| 127 |
+
# List contracts by creator
|
| 128 |
+
admin_contracts = await contract_engine.list_contracts(created_by=created_by)
|
| 129 |
+
assert len(admin_contracts) == 2
|
| 130 |
+
|
| 131 |
+
@pytest.mark.asyncio
|
| 132 |
+
async def test_propose_contract(self, contract_engine, sample_contract_request):
|
| 133 |
+
"""Test proposing a contract."""
|
| 134 |
+
created_by = "admin"
|
| 135 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 136 |
+
|
| 137 |
+
proposal_request = ContractProposeRequest(
|
| 138 |
+
proposed_to=["provider1", "patient1"],
|
| 139 |
+
message="Please review and sign this contract"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
proposed_contract = await contract_engine.propose_contract(
|
| 143 |
+
contract.id, proposal_request, created_by
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
assert proposed_contract is not None
|
| 147 |
+
assert proposed_contract.state == ContractState.PROPOSED
|
| 148 |
+
assert proposed_contract.proposed_at is not None
|
| 149 |
+
|
| 150 |
+
@pytest.mark.asyncio
|
| 151 |
+
async def test_propose_contract_invalid_state(self, contract_engine, sample_contract_request):
|
| 152 |
+
"""Test proposing a contract in invalid state."""
|
| 153 |
+
created_by = "admin"
|
| 154 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 155 |
+
|
| 156 |
+
# First propose the contract
|
| 157 |
+
proposal_request = ContractProposeRequest(proposed_to=["provider1"])
|
| 158 |
+
await contract_engine.propose_contract(contract.id, proposal_request, created_by)
|
| 159 |
+
|
| 160 |
+
# Try to propose again (should fail)
|
| 161 |
+
with pytest.raises(ValueError, match="Cannot propose contract in state ContractState.PROPOSED"):
|
| 162 |
+
await contract_engine.propose_contract(contract.id, proposal_request, created_by)
|
| 163 |
+
|
| 164 |
+
@pytest.mark.asyncio
|
| 165 |
+
async def test_sign_contract(self, contract_engine, sample_contract_request):
|
| 166 |
+
"""Test signing a contract."""
|
| 167 |
+
created_by = "admin"
|
| 168 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 169 |
+
|
| 170 |
+
# Propose the contract
|
| 171 |
+
proposal_request = ContractProposeRequest(proposed_to=["provider1"])
|
| 172 |
+
await contract_engine.propose_contract(contract.id, proposal_request, created_by)
|
| 173 |
+
|
| 174 |
+
# Generate key pair for signing
|
| 175 |
+
public_key, private_key = generate_key_pair()
|
| 176 |
+
signer = Ed25519Signer.from_private_key_b64(private_key)
|
| 177 |
+
|
| 178 |
+
# Create signing message
|
| 179 |
+
signing_message = f"{contract.id}:{contract.get_content_hash()}:provider1:provider"
|
| 180 |
+
signature = signer.sign(signing_message)
|
| 181 |
+
|
| 182 |
+
# Sign the contract
|
| 183 |
+
sign_request = ContractSignRequest(
|
| 184 |
+
signer_id="provider1",
|
| 185 |
+
signer_type="provider",
|
| 186 |
+
public_key=public_key,
|
| 187 |
+
signature=signature
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
signed_contract = await contract_engine.sign_contract(contract.id, sign_request)
|
| 191 |
+
|
| 192 |
+
assert signed_contract is not None
|
| 193 |
+
assert len(signed_contract.get_signatures()) == 1
|
| 194 |
+
assert signed_contract.get_signatures()[0].signer_id == "provider1"
|
| 195 |
+
|
| 196 |
+
@pytest.mark.asyncio
|
| 197 |
+
async def test_sign_contract_invalid_signature(self, contract_engine, sample_contract_request):
|
| 198 |
+
"""Test signing a contract with invalid signature."""
|
| 199 |
+
created_by = "admin"
|
| 200 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 201 |
+
|
| 202 |
+
# Propose the contract
|
| 203 |
+
proposal_request = ContractProposeRequest(proposed_to=["provider1"])
|
| 204 |
+
await contract_engine.propose_contract(contract.id, proposal_request, created_by)
|
| 205 |
+
|
| 206 |
+
# Generate key pair for signing
|
| 207 |
+
public_key, private_key = generate_key_pair()
|
| 208 |
+
signer = Ed25519Signer.from_private_key_b64(private_key)
|
| 209 |
+
|
| 210 |
+
# Create invalid signing message (wrong content hash)
|
| 211 |
+
invalid_signing_message = f"{contract.id}:invalid_hash:provider1:provider"
|
| 212 |
+
signature = signer.sign(invalid_signing_message)
|
| 213 |
+
|
| 214 |
+
# Try to sign with invalid signature
|
| 215 |
+
sign_request = ContractSignRequest(
|
| 216 |
+
signer_id="provider1",
|
| 217 |
+
signer_type="provider",
|
| 218 |
+
public_key=public_key,
|
| 219 |
+
signature=signature
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
with pytest.raises(ValueError, match="Invalid signature"):
|
| 223 |
+
await contract_engine.sign_contract(contract.id, sign_request)
|
| 224 |
+
|
| 225 |
+
@pytest.mark.asyncio
|
| 226 |
+
async def test_revoke_contract(self, contract_engine, sample_contract_request):
|
| 227 |
+
"""Test revoking a contract."""
|
| 228 |
+
created_by = "admin"
|
| 229 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 230 |
+
|
| 231 |
+
revoke_request = ContractRevokeRequest(
|
| 232 |
+
reason="Contract terms violated",
|
| 233 |
+
revoked_by="admin"
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
revoked_contract = await contract_engine.revoke_contract(contract.id, revoke_request)
|
| 237 |
+
|
| 238 |
+
assert revoked_contract is not None
|
| 239 |
+
assert revoked_contract.state == ContractState.REVOKED
|
| 240 |
+
assert revoked_contract.revoked_at is not None
|
| 241 |
+
|
| 242 |
+
@pytest.mark.asyncio
|
| 243 |
+
async def test_revoke_contract_already_revoked(self, contract_engine, sample_contract_request):
|
| 244 |
+
"""Test revoking an already revoked contract."""
|
| 245 |
+
created_by = "admin"
|
| 246 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 247 |
+
|
| 248 |
+
# First revoke the contract
|
| 249 |
+
revoke_request = ContractRevokeRequest(
|
| 250 |
+
reason="Contract terms violated",
|
| 251 |
+
revoked_by="admin"
|
| 252 |
+
)
|
| 253 |
+
await contract_engine.revoke_contract(contract.id, revoke_request)
|
| 254 |
+
|
| 255 |
+
# Try to revoke again (should fail)
|
| 256 |
+
with pytest.raises(ValueError, match="Contract is already revoked"):
|
| 257 |
+
await contract_engine.revoke_contract(contract.id, revoke_request)
|
| 258 |
+
|
| 259 |
+
@pytest.mark.asyncio
|
| 260 |
+
async def test_get_contracts_by_party(self, contract_engine, sample_contract_request):
|
| 261 |
+
"""Test getting contracts by party."""
|
| 262 |
+
created_by = "admin"
|
| 263 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 264 |
+
|
| 265 |
+
# Get contracts for provider1
|
| 266 |
+
provider_contracts = await contract_engine.get_contracts_by_party("provider1")
|
| 267 |
+
assert len(provider_contracts) == 1
|
| 268 |
+
assert provider_contracts[0].id == contract.id
|
| 269 |
+
|
| 270 |
+
# Get contracts for non-existent party
|
| 271 |
+
empty_contracts = await contract_engine.get_contracts_by_party("nonexistent")
|
| 272 |
+
assert len(empty_contracts) == 0
|
| 273 |
+
|
| 274 |
+
@pytest.mark.asyncio
|
| 275 |
+
async def test_cleanup_expired_contracts(self, contract_engine, sample_contract_request):
|
| 276 |
+
"""Test cleanup of expired contracts."""
|
| 277 |
+
created_by = "admin"
|
| 278 |
+
|
| 279 |
+
# Create contract with past expiration
|
| 280 |
+
past_expiration = datetime.utcnow() - timedelta(days=1)
|
| 281 |
+
sample_contract_request.expires_at = past_expiration
|
| 282 |
+
|
| 283 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 284 |
+
|
| 285 |
+
# Cleanup expired contracts
|
| 286 |
+
count = await contract_engine.cleanup_expired_contracts()
|
| 287 |
+
|
| 288 |
+
assert count == 1
|
| 289 |
+
|
| 290 |
+
# Verify contract is marked as expired
|
| 291 |
+
expired_contract = await contract_engine.get_contract(contract.id)
|
| 292 |
+
assert expired_contract.state == ContractState.EXPIRED
|
| 293 |
+
|
| 294 |
+
@pytest.mark.asyncio
|
| 295 |
+
async def test_get_contract_statistics(self, contract_engine, sample_contract_request):
|
| 296 |
+
"""Test getting contract statistics."""
|
| 297 |
+
created_by = "admin"
|
| 298 |
+
|
| 299 |
+
# Create multiple contracts
|
| 300 |
+
contract1 = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 301 |
+
contract2 = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 302 |
+
|
| 303 |
+
# Propose one contract
|
| 304 |
+
proposal_request = ContractProposeRequest(proposed_to=["provider1"])
|
| 305 |
+
await contract_engine.propose_contract(contract1.id, proposal_request, created_by)
|
| 306 |
+
|
| 307 |
+
# Get statistics
|
| 308 |
+
stats = await contract_engine.get_contract_statistics()
|
| 309 |
+
|
| 310 |
+
assert stats["total_contracts"] == 2
|
| 311 |
+
assert stats["by_state"]["draft"] == 1
|
| 312 |
+
assert stats["by_state"]["proposed"] == 1
|
| 313 |
+
assert stats["hipaa_compliant"] == 2
|
| 314 |
+
assert stats["signed_contracts"] == 0
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
class TestContractLifecycle:
|
| 318 |
+
"""Test complete contract lifecycle."""
|
| 319 |
+
|
| 320 |
+
@pytest.fixture
|
| 321 |
+
def contract_engine(self):
|
| 322 |
+
"""Create a contract engine for testing."""
|
| 323 |
+
return ContractEngine("sqlite:///:memory:")
|
| 324 |
+
|
| 325 |
+
@pytest.fixture
|
| 326 |
+
def sample_contract_request(self):
|
| 327 |
+
"""Create a sample contract creation request."""
|
| 328 |
+
return ContractCreateRequest(
|
| 329 |
+
title="Test Contract",
|
| 330 |
+
description="A test contract for lifecycle testing",
|
| 331 |
+
clauses=[
|
| 332 |
+
Clause(
|
| 333 |
+
title="Test Clause",
|
| 334 |
+
content="This is a test clause",
|
| 335 |
+
type="test"
|
| 336 |
+
)
|
| 337 |
+
],
|
| 338 |
+
parties=[
|
| 339 |
+
{
|
| 340 |
+
"id": "party1",
|
| 341 |
+
"name": "Test Party 1",
|
| 342 |
+
"type": "provider"
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"id": "party2",
|
| 346 |
+
"name": "Test Party 2",
|
| 347 |
+
"type": "patient"
|
| 348 |
+
}
|
| 349 |
+
]
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
@pytest.mark.asyncio
|
| 353 |
+
async def test_complete_contract_lifecycle(self, contract_engine, sample_contract_request):
|
| 354 |
+
"""Test complete contract lifecycle: create → propose → sign → revoke."""
|
| 355 |
+
created_by = "admin"
|
| 356 |
+
|
| 357 |
+
# 1. Create contract
|
| 358 |
+
contract = await contract_engine.create_contract(sample_contract_request, created_by)
|
| 359 |
+
assert contract.state == ContractState.DRAFT
|
| 360 |
+
|
| 361 |
+
# 2. Propose contract
|
| 362 |
+
proposal_request = ContractProposeRequest(
|
| 363 |
+
proposed_to=["party1", "party2"],
|
| 364 |
+
message="Please review and sign"
|
| 365 |
+
)
|
| 366 |
+
contract = await contract_engine.propose_contract(contract.id, proposal_request, created_by)
|
| 367 |
+
assert contract.state == ContractState.PROPOSED
|
| 368 |
+
|
| 369 |
+
# 3. Sign contract (party1)
|
| 370 |
+
public_key1, private_key1 = generate_key_pair()
|
| 371 |
+
signer1 = Ed25519Signer.from_private_key_b64(private_key1)
|
| 372 |
+
signing_message1 = f"{contract.id}:{contract.get_content_hash()}:party1:provider"
|
| 373 |
+
signature1 = signer1.sign(signing_message1)
|
| 374 |
+
|
| 375 |
+
sign_request1 = ContractSignRequest(
|
| 376 |
+
signer_id="party1",
|
| 377 |
+
signer_type="provider",
|
| 378 |
+
public_key=public_key1,
|
| 379 |
+
signature=signature1
|
| 380 |
+
)
|
| 381 |
+
contract = await contract_engine.sign_contract(contract.id, sign_request1)
|
| 382 |
+
assert len(contract.get_signatures()) == 1
|
| 383 |
+
assert contract.state == ContractState.PROPOSED # Still proposed until all parties sign
|
| 384 |
+
|
| 385 |
+
# 4. Sign contract (party2)
|
| 386 |
+
public_key2, private_key2 = generate_key_pair()
|
| 387 |
+
signer2 = Ed25519Signer.from_private_key_b64(private_key2)
|
| 388 |
+
signing_message2 = f"{contract.id}:{contract.get_content_hash()}:party2:patient"
|
| 389 |
+
signature2 = signer2.sign(signing_message2)
|
| 390 |
+
|
| 391 |
+
sign_request2 = ContractSignRequest(
|
| 392 |
+
signer_id="party2",
|
| 393 |
+
signer_type="patient",
|
| 394 |
+
public_key=public_key2,
|
| 395 |
+
signature=signature2
|
| 396 |
+
)
|
| 397 |
+
contract = await contract_engine.sign_contract(contract.id, sign_request2)
|
| 398 |
+
assert len(contract.get_signatures()) == 2
|
| 399 |
+
assert contract.state == ContractState.SIGNED # Now fully signed
|
| 400 |
+
|
| 401 |
+
# 5. Revoke contract
|
| 402 |
+
revoke_request = ContractRevokeRequest(
|
| 403 |
+
reason="Contract terms violated",
|
| 404 |
+
revoked_by="admin"
|
| 405 |
+
)
|
| 406 |
+
contract = await contract_engine.revoke_contract(contract.id, revoke_request)
|
| 407 |
+
assert contract.state == ContractState.REVOKED
|
| 408 |
+
assert contract.revoked_at is not None
|
tests/contracts/test_contract_http.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for contract HTTP endpoints."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from datetime import datetime, timedelta
|
| 5 |
+
from uuid import UUID
|
| 6 |
+
|
| 7 |
+
from fastmcp import FastMCP
|
| 8 |
+
from fastmcp.contracts import ContractEngine, ContractState
|
| 9 |
+
from fastmcp.contracts.contract import (
|
| 10 |
+
Clause, ContractCreateRequest, ContractProposeRequest,
|
| 11 |
+
ContractSignRequest, ContractRevokeRequest
|
| 12 |
+
)
|
| 13 |
+
from fastmcp.contracts.crypto import generate_key_pair, Ed25519Signer
|
| 14 |
+
from starlette.routing import Route
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class TestContractHTTPEndpoint:
|
| 18 |
+
"""Test the contract management HTTP endpoints."""
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def server_with_contracts(self):
|
| 22 |
+
"""Create a server with contract engine enabled."""
|
| 23 |
+
server = FastMCP("Test Contract Server")
|
| 24 |
+
# Use in-memory database for testing
|
| 25 |
+
contract_engine = server.enable_contract_engine(database_url="sqlite:///:memory:")
|
| 26 |
+
return server
|
| 27 |
+
|
| 28 |
+
@pytest.fixture
|
| 29 |
+
def app(self, server_with_contracts):
|
| 30 |
+
"""Create the HTTP app with contract endpoints."""
|
| 31 |
+
return server_with_contracts.http_app(transport="sse")
|
| 32 |
+
|
| 33 |
+
def test_contract_endpoints_exist(self, app):
|
| 34 |
+
"""Test that all contract endpoints exist in the app."""
|
| 35 |
+
expected_paths = [
|
| 36 |
+
"/contracts",
|
| 37 |
+
"/contracts/{id}",
|
| 38 |
+
"/contracts/{id}/propose",
|
| 39 |
+
"/contracts/{id}/sign",
|
| 40 |
+
"/contracts/{id}/revoke",
|
| 41 |
+
"/contracts/statistics"
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
# Check that all contract routes exist
|
| 45 |
+
contract_routes_found = set()
|
| 46 |
+
for route in app.routes:
|
| 47 |
+
if isinstance(route, Route):
|
| 48 |
+
if route.path in expected_paths:
|
| 49 |
+
contract_routes_found.add(route.path)
|
| 50 |
+
|
| 51 |
+
assert len(contract_routes_found) == len(expected_paths), f"Expected {len(expected_paths)} unique contract routes, found {len(contract_routes_found)}: {sorted(contract_routes_found)}"
|
| 52 |
+
|
| 53 |
+
def test_contract_engine_integration(self, server_with_contracts):
|
| 54 |
+
"""Test that contract engine is properly integrated with the server."""
|
| 55 |
+
# Check that contract engine is enabled
|
| 56 |
+
assert server_with_contracts.get_contract_engine() is not None
|
| 57 |
+
|
| 58 |
+
# Check that contract engine is the right type
|
| 59 |
+
contract_engine = server_with_contracts.get_contract_engine()
|
| 60 |
+
assert isinstance(contract_engine, ContractEngine)
|
| 61 |
+
|
| 62 |
+
@pytest.mark.asyncio
|
| 63 |
+
async def test_create_contract_endpoint(self, app):
|
| 64 |
+
"""Test the create contract endpoint."""
|
| 65 |
+
import httpx
|
| 66 |
+
|
| 67 |
+
contract_data = {
|
| 68 |
+
"title": "Test Contract",
|
| 69 |
+
"description": "A test contract",
|
| 70 |
+
"clauses": [
|
| 71 |
+
{
|
| 72 |
+
"title": "Test Clause",
|
| 73 |
+
"content": "This is a test clause",
|
| 74 |
+
"type": "test"
|
| 75 |
+
}
|
| 76 |
+
],
|
| 77 |
+
"parties": [
|
| 78 |
+
{
|
| 79 |
+
"id": "party1",
|
| 80 |
+
"name": "Test Party",
|
| 81 |
+
"type": "provider"
|
| 82 |
+
}
|
| 83 |
+
],
|
| 84 |
+
"is_hipaa_compliant": False
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
| 88 |
+
response = await client.post("/contracts", json=contract_data)
|
| 89 |
+
|
| 90 |
+
assert response.status_code == 201
|
| 91 |
+
data = response.json()
|
| 92 |
+
assert data["title"] == contract_data["title"]
|
| 93 |
+
assert data["description"] == contract_data["description"]
|
| 94 |
+
assert data["state"] == "draft"
|
| 95 |
+
assert data["is_hipaa_compliant"] is False
|
| 96 |
+
|
| 97 |
+
@pytest.mark.asyncio
|
| 98 |
+
async def test_get_contract_endpoint(self, app):
|
| 99 |
+
"""Test the get contract endpoint."""
|
| 100 |
+
import httpx
|
| 101 |
+
|
| 102 |
+
# First create a contract
|
| 103 |
+
contract_data = {
|
| 104 |
+
"title": "Test Contract",
|
| 105 |
+
"description": "A test contract",
|
| 106 |
+
"clauses": [],
|
| 107 |
+
"parties": []
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
| 111 |
+
# Create contract
|
| 112 |
+
create_response = await client.post("/contracts", json=contract_data)
|
| 113 |
+
assert create_response.status_code == 201
|
| 114 |
+
contract_id = create_response.json()["id"]
|
| 115 |
+
|
| 116 |
+
# Get contract
|
| 117 |
+
get_response = await client.get(f"/contracts/{contract_id}")
|
| 118 |
+
assert get_response.status_code == 200
|
| 119 |
+
data = get_response.json()
|
| 120 |
+
assert data["id"] == contract_id
|
| 121 |
+
assert data["title"] == contract_data["title"]
|
| 122 |
+
|
| 123 |
+
@pytest.mark.asyncio
|
| 124 |
+
async def test_get_contract_not_found(self, app):
|
| 125 |
+
"""Test getting a non-existent contract."""
|
| 126 |
+
import httpx
|
| 127 |
+
|
| 128 |
+
fake_id = "12345678-1234-1234-1234-123456789012"
|
| 129 |
+
|
| 130 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
| 131 |
+
response = await client.get(f"/contracts/{fake_id}")
|
| 132 |
+
assert response.status_code == 404
|
| 133 |
+
data = response.json()
|
| 134 |
+
assert "not found" in data["error"].lower()
|
| 135 |
+
|
| 136 |
+
@pytest.mark.asyncio
|
| 137 |
+
async def test_list_contracts_endpoint(self, app):
|
| 138 |
+
"""Test the list contracts endpoint."""
|
| 139 |
+
import httpx
|
| 140 |
+
|
| 141 |
+
# Create multiple contracts
|
| 142 |
+
contract_data = {
|
| 143 |
+
"title": "Test Contract",
|
| 144 |
+
"description": "A test contract",
|
| 145 |
+
"clauses": [],
|
| 146 |
+
"parties": []
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
| 150 |
+
# Create contracts
|
| 151 |
+
await client.post("/contracts", json=contract_data)
|
| 152 |
+
await client.post("/contracts", json=contract_data)
|
| 153 |
+
|
| 154 |
+
# List contracts
|
| 155 |
+
response = await client.get("/contracts")
|
| 156 |
+
assert response.status_code == 200
|
| 157 |
+
data = response.json()
|
| 158 |
+
assert "contracts" in data
|
| 159 |
+
assert "count" in data
|
| 160 |
+
assert data["count"] == 2
|
| 161 |
+
assert len(data["contracts"]) == 2
|
| 162 |
+
|
| 163 |
+
@pytest.mark.asyncio
|
| 164 |
+
async def test_propose_contract_endpoint(self, app):
|
| 165 |
+
"""Test the propose contract endpoint."""
|
| 166 |
+
import httpx
|
| 167 |
+
|
| 168 |
+
# First create a contract
|
| 169 |
+
contract_data = {
|
| 170 |
+
"title": "Test Contract",
|
| 171 |
+
"description": "A test contract",
|
| 172 |
+
"clauses": [],
|
| 173 |
+
"parties": []
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
| 177 |
+
# Create contract
|
| 178 |
+
create_response = await client.post("/contracts", json=contract_data)
|
| 179 |
+
contract_id = create_response.json()["id"]
|
| 180 |
+
|
| 181 |
+
# Propose contract
|
| 182 |
+
proposal_data = {
|
| 183 |
+
"proposed_to": ["party1", "party2"],
|
| 184 |
+
"message": "Please review and sign"
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
response = await client.post(f"/contracts/{contract_id}/propose", json=proposal_data)
|
| 188 |
+
assert response.status_code == 200
|
| 189 |
+
data = response.json()
|
| 190 |
+
assert data["state"] == "proposed"
|
| 191 |
+
assert data["proposed_at"] is not None
|
| 192 |
+
|
| 193 |
+
@pytest.mark.asyncio
|
| 194 |
+
async def test_sign_contract_endpoint(self, app):
|
| 195 |
+
"""Test the sign contract endpoint."""
|
| 196 |
+
import httpx
|
| 197 |
+
|
| 198 |
+
# First create and propose a contract
|
| 199 |
+
contract_data = {
|
| 200 |
+
"title": "Test Contract",
|
| 201 |
+
"description": "A test contract",
|
| 202 |
+
"clauses": [],
|
| 203 |
+
"parties": [{"id": "party1", "name": "Test Party", "type": "provider"}]
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
| 207 |
+
# Create contract
|
| 208 |
+
create_response = await client.post("/contracts", json=contract_data)
|
| 209 |
+
contract_id = create_response.json()["id"]
|
| 210 |
+
|
| 211 |
+
# Propose contract
|
| 212 |
+
proposal_data = {"proposed_to": ["party1"]}
|
| 213 |
+
await client.post(f"/contracts/{contract_id}/propose", json=proposal_data)
|
| 214 |
+
|
| 215 |
+
# Generate key pair for signing
|
| 216 |
+
public_key, private_key = generate_key_pair()
|
| 217 |
+
signer = Ed25519Signer.from_private_key_b64(private_key)
|
| 218 |
+
|
| 219 |
+
# Get contract to get content hash
|
| 220 |
+
contract_response = await client.get(f"/contracts/{contract_id}")
|
| 221 |
+
contract_data_response = contract_response.json()
|
| 222 |
+
content_hash = contract_data_response["content_hash"]
|
| 223 |
+
|
| 224 |
+
# Create signature
|
| 225 |
+
signing_message = f"{contract_id}:{content_hash}:party1:provider"
|
| 226 |
+
signature = signer.sign(signing_message)
|
| 227 |
+
|
| 228 |
+
# Sign contract
|
| 229 |
+
sign_data = {
|
| 230 |
+
"signer_id": "party1",
|
| 231 |
+
"signer_type": "provider",
|
| 232 |
+
"public_key": public_key,
|
| 233 |
+
"signature": signature
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
response = await client.post(f"/contracts/{contract_id}/sign", json=sign_data)
|
| 237 |
+
assert response.status_code == 200
|
| 238 |
+
data = response.json()
|
| 239 |
+
assert len(data["signatures"]) == 1
|
| 240 |
+
assert data["signatures"][0]["signer_id"] == "party1"
|
| 241 |
+
|
| 242 |
+
@pytest.mark.asyncio
|
| 243 |
+
async def test_revoke_contract_endpoint(self, app):
|
| 244 |
+
"""Test the revoke contract endpoint."""
|
| 245 |
+
import httpx
|
| 246 |
+
|
| 247 |
+
# First create a contract
|
| 248 |
+
contract_data = {
|
| 249 |
+
"title": "Test Contract",
|
| 250 |
+
"description": "A test contract",
|
| 251 |
+
"clauses": [],
|
| 252 |
+
"parties": []
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
| 256 |
+
# Create contract
|
| 257 |
+
create_response = await client.post("/contracts", json=contract_data)
|
| 258 |
+
contract_id = create_response.json()["id"]
|
| 259 |
+
|
| 260 |
+
# Revoke contract
|
| 261 |
+
revoke_data = {
|
| 262 |
+
"reason": "Contract terms violated",
|
| 263 |
+
"revoked_by": "admin"
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
response = await client.post(f"/contracts/{contract_id}/revoke", json=revoke_data)
|
| 267 |
+
assert response.status_code == 200
|
| 268 |
+
data = response.json()
|
| 269 |
+
assert data["state"] == "revoked"
|
| 270 |
+
assert data["revoked_at"] is not None
|
| 271 |
+
|
| 272 |
+
@pytest.mark.asyncio
|
| 273 |
+
async def test_contract_statistics_endpoint(self, app):
|
| 274 |
+
"""Test the contract statistics endpoint."""
|
| 275 |
+
import httpx
|
| 276 |
+
|
| 277 |
+
# Create some contracts
|
| 278 |
+
contract_data = {
|
| 279 |
+
"title": "Test Contract",
|
| 280 |
+
"description": "A test contract",
|
| 281 |
+
"clauses": [],
|
| 282 |
+
"parties": []
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
| 286 |
+
# Create contracts
|
| 287 |
+
await client.post("/contracts", json=contract_data)
|
| 288 |
+
await client.post("/contracts", json=contract_data)
|
| 289 |
+
|
| 290 |
+
# Get statistics
|
| 291 |
+
response = await client.get("/contracts/statistics")
|
| 292 |
+
assert response.status_code == 200
|
| 293 |
+
data = response.json()
|
| 294 |
+
assert "total_contracts" in data
|
| 295 |
+
assert "by_state" in data
|
| 296 |
+
assert "hipaa_compliant" in data
|
| 297 |
+
assert "signed_contracts" in data
|
| 298 |
+
assert data["total_contracts"] == 2
|