svkrishna commited on
Commit
4d56236
·
1 Parent(s): 78b489c

🤖 Implement Story 2: Inter-Agent Contract Module

Browse files

- Add complete contract lifecycle management with cryptographic guarantees
- Implement Ed25519 signature verification using PyNaCl for non-repudiation
- Add Pydantic + SQLModel schemas for Contract, Clause, Signature, ContractState
- Create HTTP endpoints: POST /contracts, GET /contracts/{id}, POST /contracts/{id}/propose, POST /contracts/{id}/sign, POST /contracts/{id}/revoke, GET /contracts/statistics
- Add SQLite persistence with proper state transitions and audit trails
- Support HIPAA compliance with entity tracking
- Include comprehensive test suite with 25 passing tests
- Integrate seamlessly with FastMCP server architecture

All acceptance criteria met: cryptographic guarantees, lifecycle management, persistence, and complete test coverage.

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
@@ -64,6 +64,7 @@ from fastmcp.tools import ToolManager
64
  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.utilities.cli import log_server_banner
68
  from fastmcp.utilities.components import FastMCPComponent
69
  from fastmcp.utilities.logging import get_logger
@@ -176,6 +177,7 @@ class FastMCP(Generic[LifespanResultT]):
176
  self._additional_http_routes: list[BaseRoute] = []
177
  self._mounted_servers: list[MountedServer] = []
178
  self._policy_engine: Optional[PolicyEngine] = None
 
179
  self._tool_manager = ToolManager(
180
  duplicate_behavior=on_duplicate_tools,
181
  mask_error_details=mask_error_details,
@@ -514,6 +516,13 @@ class FastMCP(Generic[LifespanResultT]):
514
  policy_route = create_policy_evaluate_route(self._policy_engine)
515
  routes.append(policy_route)
516
 
 
 
 
 
 
 
 
517
  # Recursively get routes from mounted servers
518
  for mounted_server in self._mounted_servers:
519
  mounted_routes = mounted_server.server._get_additional_http_routes()
@@ -544,6 +553,31 @@ class FastMCP(Generic[LifespanResultT]):
544
  The policy engine instance, or None if not enabled
545
  """
546
  return self._policy_engine
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
547
 
548
  async def _mcp_list_tools(self) -> list[MCPTool]:
549
  logger.debug("Handler called: list_tools")
 
64
  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.contracts import ContractEngine
68
  from fastmcp.utilities.cli import log_server_banner
69
  from fastmcp.utilities.components import FastMCPComponent
70
  from fastmcp.utilities.logging import get_logger
 
177
  self._additional_http_routes: list[BaseRoute] = []
178
  self._mounted_servers: list[MountedServer] = []
179
  self._policy_engine: Optional[PolicyEngine] = None
180
+ self._contract_engine: Optional[ContractEngine] = None
181
  self._tool_manager = ToolManager(
182
  duplicate_behavior=on_duplicate_tools,
183
  mask_error_details=mask_error_details,
 
516
  policy_route = create_policy_evaluate_route(self._policy_engine)
517
  routes.append(policy_route)
518
 
519
+ # Add contract management endpoints if contract engine is configured
520
+ if self._contract_engine is not None:
521
+ from fastmcp.server.contract_routes import create_contract_routes
522
+
523
+ contract_routes = create_contract_routes(self._contract_engine)
524
+ routes.extend(contract_routes)
525
+
526
  # Recursively get routes from mounted servers
527
  for mounted_server in self._mounted_servers:
528
  mounted_routes = mounted_server.server._get_additional_http_routes()
 
553
  The policy engine instance, or None if not enabled
554
  """
555
  return self._policy_engine
556
+
557
+ def enable_contract_engine(self, contract_engine: Optional[ContractEngine] = None, database_url: str = "sqlite:///contracts.db") -> ContractEngine:
558
+ """Enable the contract engine for this server.
559
+
560
+ Args:
561
+ contract_engine: Optional contract engine instance. If None, creates a new one.
562
+ database_url: Database URL for contract persistence
563
+
564
+ Returns:
565
+ The contract engine instance
566
+ """
567
+ if contract_engine is None:
568
+ contract_engine = ContractEngine(database_url)
569
+
570
+ self._contract_engine = contract_engine
571
+ logger.info("Contract engine enabled for server")
572
+ return contract_engine
573
+
574
+ def get_contract_engine(self) -> Optional[ContractEngine]:
575
+ """Get the contract engine instance.
576
+
577
+ Returns:
578
+ The contract engine instance, or None if not enabled
579
+ """
580
+ return self._contract_engine
581
 
582
  async def _mcp_list_tools(self) -> list[MCPTool]:
583
  logger.debug("Handler called: list_tools")
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