@macaw-ai/client 0.0.0-stage → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/LICENSE.md +339 -0
  2. package/bin/install.js +278 -0
  3. package/package.json +37 -4
  4. package/payload/README.md +56 -0
  5. package/payload/secureAI/LICENSE +190 -0
  6. package/payload/secureAI/NOTICE +5 -0
  7. package/payload/secureAI/demos/policy-controls/1_uncontrolled.py +139 -0
  8. package/payload/secureAI/demos/policy-controls/2_with_macaw.py +139 -0
  9. package/payload/secureAI/demos/policy-controls/3_with_user_policies.py +162 -0
  10. package/payload/secureAI/demos/policy-controls/credentials.txt +12 -0
  11. package/payload/secureAI/demos/policy-controls/malicious_report.txt +21 -0
  12. package/payload/secureAI/demos/policy-controls/sales_report.txt +20 -0
  13. package/payload/secureAI/demos/tutorial-1-rbac/README.md +143 -0
  14. package/payload/secureAI/demos/tutorial-1-rbac/app.py +147 -0
  15. package/payload/secureAI/demos/tutorial-1-rbac/authprompts_demo.py +306 -0
  16. package/payload/secureAI/demos/tutorial-1-rbac/config/claims-config.yaml +152 -0
  17. package/payload/secureAI/demos/tutorial-1-rbac/demo_secureopenai_with_policies.py +150 -0
  18. package/payload/secureAI/demos/tutorial-1-rbac/policies/bu_Analytics.json +8 -0
  19. package/payload/secureAI/demos/tutorial-1-rbac/policies/company_FinTech_Corp.json +70 -0
  20. package/payload/secureAI/demos/tutorial-1-rbac/policies/llm-base.json +69 -0
  21. package/payload/secureAI/demos/tutorial-1-rbac/policies/team_Reporting.json +8 -0
  22. package/payload/secureAI/demos/tutorial-1-rbac/policies/user_alice.json +32 -0
  23. package/payload/secureAI/demos/tutorial-1-rbac/policies/user_bob.json +28 -0
  24. package/payload/secureAI/demos/tutorial-1-rbac/policies/user_carol.json +34 -0
  25. package/payload/secureAI/demos/tutorial-1-rbac/requirements.txt +6 -0
  26. package/payload/secureAI/demos/tutorial-1-rbac/setup/auth0_complete_setup.sh +392 -0
  27. package/payload/secureAI/demos/tutorial-1-rbac/setup/keycloak_complete_setup.sh +320 -0
  28. package/payload/secureAI/demos/tutorial-2-intent/Policies/alation-remote-proxy.json +155 -0
  29. package/payload/secureAI/demos/tutorial-2-intent/Policies/bu.json +22 -0
  30. package/payload/secureAI/demos/tutorial-2-intent/Policies/company.json +17 -0
  31. package/payload/secureAI/demos/tutorial-2-intent/Policies/user_alice.json +48 -0
  32. package/payload/secureAI/demos/tutorial-2-intent/Policies/user_bob.json +83 -0
  33. package/payload/secureAI/demos/tutorial-2-intent/README.md +213 -0
  34. package/payload/secureAI/demos/tutorial-2-intent/script.py +188 -0
  35. package/payload/secureAI/demos/tutorial-2-intent/setup/alation_verifier.py +177 -0
  36. package/payload/secureAI/demos/tutorial-2-intent/setup/get_alation_user_token.py +180 -0
  37. package/payload/secureAI/examples/README.md +244 -0
  38. package/payload/secureAI/examples/anthropic/anthropic_1a_dropin_simple.py +103 -0
  39. package/payload/secureAI/examples/anthropic/anthropic_1b_multiuser_bind.py +190 -0
  40. package/payload/secureAI/examples/anthropic/anthropic_1b_multiuser_bind_streaming.py +190 -0
  41. package/payload/secureAI/examples/anthropic/anthropic_1c_a2a_invoke.py +156 -0
  42. package/payload/secureAI/examples/attestations/1a_trade_alice.py +215 -0
  43. package/payload/secureAI/examples/attestations/1a_trade_bob.py +164 -0
  44. package/payload/secureAI/examples/attestations/1b_grant_admin.py +170 -0
  45. package/payload/secureAI/examples/attestations/1b_grant_researcher.py +231 -0
  46. package/payload/secureAI/examples/attestations/README.md +128 -0
  47. package/payload/secureAI/examples/langchain/langchain_1a_dropin_simple.py +199 -0
  48. package/payload/secureAI/examples/langchain/langchain_1b_multiuser.py +262 -0
  49. package/payload/secureAI/examples/langchain/langchain_1c_orchestration.py +332 -0
  50. package/payload/secureAI/examples/langchain/langchain_1d_llm_openai.py +130 -0
  51. package/payload/secureAI/examples/langchain/langchain_1e_llm_anthropic.py +130 -0
  52. package/payload/secureAI/examples/langchain/langchain_1f_memory.py +214 -0
  53. package/payload/secureAI/examples/litellm/litellm_1a_dropin_simple.py +131 -0
  54. package/payload/secureAI/examples/mcp/1a_simple_invocation.py +115 -0
  55. package/payload/secureAI/examples/mcp/1b_discovery_and_resources.py +142 -0
  56. package/payload/secureAI/examples/mcp/1c_logging_client.py +100 -0
  57. package/payload/secureAI/examples/mcp/1d_progress_client.py +100 -0
  58. package/payload/secureAI/examples/mcp/1e_sampling_client.py +150 -0
  59. package/payload/secureAI/examples/mcp/1e_sampling_server.py +90 -0
  60. package/payload/secureAI/examples/mcp/1f_elicitation_client.py +127 -0
  61. package/payload/secureAI/examples/mcp/1f_elicitation_server.py +73 -0
  62. package/payload/secureAI/examples/mcp/1g_roots_client.py +122 -0
  63. package/payload/secureAI/examples/mcp/1g_roots_server.py +85 -0
  64. package/payload/secureAI/examples/mcp/2a_proxy_snowflake.py +212 -0
  65. package/payload/secureAI/examples/mcp/2b_proxy_databricks.py +299 -0
  66. package/payload/secureAI/examples/mcp/2c_proxy_stdio_fetch.py +91 -0
  67. package/payload/secureAI/examples/mcp/2d_proxy_stdio_salesforce.py +119 -0
  68. package/payload/secureAI/examples/mcp/2e_proxy_multiuser.py +136 -0
  69. package/payload/secureAI/examples/mcp/2f_proxy_stdio_databricks.py +144 -0
  70. package/payload/secureAI/examples/mcp/3a_native_mcp_client.py +104 -0
  71. package/payload/secureAI/examples/mcp/3b_native_mcp_proxy.py +114 -0
  72. package/payload/secureAI/examples/mcp/3c_native_mcp_sampling.py +180 -0
  73. package/payload/secureAI/examples/mcp/3d_native_mcp_2026.py +135 -0
  74. package/payload/secureAI/examples/mcp/securemcp_calculator.py +248 -0
  75. package/payload/secureAI/examples/mcp/securemcp_server.py +75 -0
  76. package/payload/secureAI/examples/openai/openai_1a_dropin_simple.py +102 -0
  77. package/payload/secureAI/examples/openai/openai_1b_multiuser_bind.py +193 -0
  78. package/payload/secureAI/examples/openai/openai_1b_multiuser_bind_streaming.py +199 -0
  79. package/payload/secureAI/examples/openai/openai_1c_a2a_invoke.py +156 -0
  80. package/payload/secureAI/examples/openai/openai_1d_invocation_patterns.py +337 -0
  81. package/payload/secureAI/examples/phishing-resistance/1a_login_alice.py +180 -0
  82. package/payload/secureAI/examples/phishing-resistance/1b_trade_alice.py +241 -0
  83. package/payload/secureAI/examples/phishing-resistance/1b_trade_bob.py +164 -0
  84. package/payload/secureAI/examples/phishing-resistance/README.md +124 -0
  85. package/payload/secureAI/examples/phishing-resistance/get_token.py +114 -0
  86. package/payload/secureAI/examples/pydantic_ai/README.md +88 -0
  87. package/payload/secureAI/examples/pydantic_ai/policies/app_pydantic-agent.json +10 -0
  88. package/payload/secureAI/examples/pydantic_ai/pydanticai_1a_dropin_simple.py +90 -0
  89. package/payload/secureAI/examples/pydantic_ai/pydanticai_1b_model_routing.py +85 -0
  90. package/payload/secureAI/examples/pydantic_ai/pydanticai_1c_native_tools.py +83 -0
  91. package/payload/secureAI/examples/pydantic_ai/pydanticai_1d_multiuser_bind.py +104 -0
  92. package/payload/secureAI/examples/pydantic_ai/pydanticai_1e_mcp_compose.py +92 -0
  93. package/payload/secureAI/macaw_adapters/__init__.py +55 -0
  94. package/payload/secureAI/macaw_adapters/anthropic/REFERENCE.md +374 -0
  95. package/payload/secureAI/macaw_adapters/anthropic/__init__.py +22 -0
  96. package/payload/secureAI/macaw_adapters/anthropic/secure_anthropic.py +792 -0
  97. package/payload/secureAI/macaw_adapters/anthropic_ext_tools/__init__.py +25 -0
  98. package/payload/secureAI/macaw_adapters/anthropic_ext_tools/secure_anthropic.py +840 -0
  99. package/payload/secureAI/macaw_adapters/langchain/REFERENCE.md +234 -0
  100. package/payload/secureAI/macaw_adapters/langchain/__init__.py +100 -0
  101. package/payload/secureAI/macaw_adapters/langchain/_utils.py +83 -0
  102. package/payload/secureAI/macaw_adapters/langchain/agents.py +296 -0
  103. package/payload/secureAI/macaw_adapters/langchain/anthropic.py +456 -0
  104. package/payload/secureAI/macaw_adapters/langchain/callbacks.py +306 -0
  105. package/payload/secureAI/macaw_adapters/langchain/memory.py +352 -0
  106. package/payload/secureAI/macaw_adapters/langchain/openai.py +410 -0
  107. package/payload/secureAI/macaw_adapters/langchain/tools.py +174 -0
  108. package/payload/secureAI/macaw_adapters/litellm/README.md +216 -0
  109. package/payload/secureAI/macaw_adapters/litellm/__init__.py +143 -0
  110. package/payload/secureAI/macaw_adapters/litellm/secure_litellm.py +686 -0
  111. package/payload/secureAI/macaw_adapters/mcp/REFERENCE.md +460 -0
  112. package/payload/secureAI/macaw_adapters/mcp/__init__.py +65 -0
  113. package/payload/secureAI/macaw_adapters/mcp/_endpoint.py +193 -0
  114. package/payload/secureAI/macaw_adapters/mcp/_endpoint2.py +225 -0
  115. package/payload/secureAI/macaw_adapters/mcp/_serve.py +25 -0
  116. package/payload/secureAI/macaw_adapters/mcp/client.py +721 -0
  117. package/payload/secureAI/macaw_adapters/mcp/decorators.py +228 -0
  118. package/payload/secureAI/macaw_adapters/mcp/fastmcp_compat.py +353 -0
  119. package/payload/secureAI/macaw_adapters/mcp/mcp.py +785 -0
  120. package/payload/secureAI/macaw_adapters/mcp/proxy.py +626 -0
  121. package/payload/secureAI/macaw_adapters/mcp/server.py +317 -0
  122. package/payload/secureAI/macaw_adapters/mcp/subscriptions.py +450 -0
  123. package/payload/secureAI/macaw_adapters/openai/REFERENCE.md +368 -0
  124. package/payload/secureAI/macaw_adapters/openai/__init__.py +22 -0
  125. package/payload/secureAI/macaw_adapters/openai/secure_openai.py +833 -0
  126. package/payload/secureAI/macaw_adapters/openai_ext_tools/REFERENCE.md +368 -0
  127. package/payload/secureAI/macaw_adapters/openai_ext_tools/__init__.py +25 -0
  128. package/payload/secureAI/macaw_adapters/openai_ext_tools/secure_openai.py +876 -0
  129. package/payload/secureAI/macaw_adapters/pydantic_ai/__init__.py +36 -0
  130. package/payload/secureAI/macaw_adapters/pydantic_ai/_core.py +257 -0
  131. package/payload/secureAI/macaw_adapters/pydantic_ai/agent.py +218 -0
  132. package/payload/secureAI/macaw_adapters/pydantic_ai/model.py +99 -0
  133. package/payload/secureAI/macaw_adapters/pydantic_ai/toolset.py +86 -0
  134. package/payload/secureAI/test_harness_quick.py +296 -0
  135. package/wheels/macaw_adapters-0.9.10.1-py3-none-any.whl +0 -0
  136. package/wheels/macaw_client-0.9.10.1-cp312-cp312-macosx_11_0_arm64.whl +0 -0
  137. package/wheels/macaw_client-0.9.10.1-cp312-cp312-manylinux_2_17_x86_64.whl +0 -0
  138. package/wheels/macaw_client-0.9.10.1-cp314-cp314-macosx_11_0_arm64.whl +0 -0
  139. package/wheels/macaw_client-0.9.10.1-cp314-cp314-manylinux_2_17_x86_64.whl +0 -0
  140. package/wheels/macaw_client-0.9.10.1-cp314-cp314-win_amd64.whl +0 -0
  141. package/wheels/macaw_client-0.9.10.1-cp39-cp39-macosx_11_0_arm64.whl +0 -0
  142. package/README.md +0 -3
@@ -0,0 +1,450 @@
1
+ """
2
+ Resource Subscriptions for SecureMCP
3
+
4
+ Enables clients to subscribe to resource changes and receive real-time updates.
5
+ When MACAW is available, updates are signed for authenticity.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import uuid
11
+ from typing import Dict, Any, List, Callable, Optional, Set
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from enum import Enum
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class SubscriptionEvent(Enum):
20
+ """Types of subscription events"""
21
+ CREATED = "created"
22
+ UPDATED = "updated"
23
+ DELETED = "deleted"
24
+ ERROR = "error"
25
+
26
+
27
+ @dataclass
28
+ class Subscription:
29
+ """Represents a resource subscription"""
30
+ subscription_id: str
31
+ client_id: str
32
+ resource_uri: str
33
+ created_at: datetime
34
+ last_update: Optional[datetime] = None
35
+ event_types: Set[SubscriptionEvent] = field(default_factory=lambda: {SubscriptionEvent.UPDATED})
36
+ metadata: Dict[str, Any] = field(default_factory=dict)
37
+
38
+ def matches_event(self, event_type: SubscriptionEvent) -> bool:
39
+ """Check if subscription should receive this event type"""
40
+ return event_type in self.event_types
41
+
42
+
43
+ @dataclass
44
+ class ResourceUpdate:
45
+ """Represents a resource update event"""
46
+ resource_uri: str
47
+ event_type: SubscriptionEvent
48
+ data: Any
49
+ timestamp: datetime
50
+ metadata: Dict[str, Any] = field(default_factory=dict)
51
+ signature: Optional[str] = None # Signature when MACAW available
52
+
53
+ def to_dict(self) -> Dict[str, Any]:
54
+ """Convert to dictionary for transmission"""
55
+ return {
56
+ "resource_uri": self.resource_uri,
57
+ "event_type": self.event_type.value,
58
+ "data": self.data,
59
+ "timestamp": self.timestamp.isoformat(),
60
+ "metadata": self.metadata,
61
+ "signature": self.signature
62
+ }
63
+
64
+
65
+ class SubscriptionManager:
66
+ """
67
+ Manages resource subscriptions for a SecureMCP server.
68
+
69
+ This class handles:
70
+ - Subscription registration/unregistration
71
+ - Event distribution to subscribers
72
+ - Subscription lifecycle management
73
+ """
74
+
75
+ def __init__(self, server=None):
76
+ """
77
+ Initialize subscription manager.
78
+
79
+ Args:
80
+ server: SecureMCP Server instance
81
+ """
82
+ self.server = server
83
+ self.subscriptions: Dict[str, Subscription] = {}
84
+ self.resource_subscriptions: Dict[str, List[str]] = {} # resource_uri -> subscription_ids
85
+ self.client_subscriptions: Dict[str, List[str]] = {} # client_id -> subscription_ids
86
+ self.update_handlers: Dict[str, Callable] = {} # client_id -> handler function
87
+
88
+ logger.info("SubscriptionManager initialized")
89
+
90
+ async def subscribe(
91
+ self,
92
+ client_id: str,
93
+ resource_uri: str,
94
+ event_types: Optional[List[SubscriptionEvent]] = None,
95
+ handler: Optional[Callable] = None
96
+ ) -> str:
97
+ """
98
+ Subscribe a client to resource updates.
99
+
100
+ Args:
101
+ client_id: ID of subscribing client
102
+ resource_uri: URI pattern of resource to subscribe to
103
+ event_types: Types of events to subscribe to
104
+ handler: Callback function for updates
105
+
106
+ Returns:
107
+ Subscription ID
108
+ """
109
+ subscription_id = f"sub-{uuid.uuid4().hex[:8]}"
110
+
111
+ # Create subscription
112
+ subscription = Subscription(
113
+ subscription_id=subscription_id,
114
+ client_id=client_id,
115
+ resource_uri=resource_uri,
116
+ created_at=datetime.now(),
117
+ event_types=set(event_types) if event_types else {SubscriptionEvent.UPDATED}
118
+ )
119
+
120
+ # Store subscription
121
+ self.subscriptions[subscription_id] = subscription
122
+
123
+ # Index by resource
124
+ if resource_uri not in self.resource_subscriptions:
125
+ self.resource_subscriptions[resource_uri] = []
126
+ self.resource_subscriptions[resource_uri].append(subscription_id)
127
+
128
+ # Index by client
129
+ if client_id not in self.client_subscriptions:
130
+ self.client_subscriptions[client_id] = []
131
+ self.client_subscriptions[client_id].append(subscription_id)
132
+
133
+ # Store handler if provided
134
+ if handler:
135
+ self.update_handlers[client_id] = handler
136
+
137
+ logger.info(f"Client {client_id} subscribed to {resource_uri}: {subscription_id}")
138
+
139
+ return subscription_id
140
+
141
+ async def unsubscribe(self, subscription_id: str) -> bool:
142
+ """
143
+ Unsubscribe from resource updates.
144
+
145
+ Args:
146
+ subscription_id: ID of subscription to remove
147
+
148
+ Returns:
149
+ True if unsubscribed successfully
150
+ """
151
+ if subscription_id not in self.subscriptions:
152
+ return False
153
+
154
+ subscription = self.subscriptions[subscription_id]
155
+
156
+ # Remove from indices
157
+ if subscription.resource_uri in self.resource_subscriptions:
158
+ self.resource_subscriptions[subscription.resource_uri].remove(subscription_id)
159
+ if not self.resource_subscriptions[subscription.resource_uri]:
160
+ del self.resource_subscriptions[subscription.resource_uri]
161
+
162
+ if subscription.client_id in self.client_subscriptions:
163
+ self.client_subscriptions[subscription.client_id].remove(subscription_id)
164
+ if not self.client_subscriptions[subscription.client_id]:
165
+ del self.client_subscriptions[subscription.client_id]
166
+
167
+ # Remove subscription
168
+ del self.subscriptions[subscription_id]
169
+
170
+ logger.info(f"Unsubscribed: {subscription_id}")
171
+ return True
172
+
173
+ async def unsubscribe_client(self, client_id: str) -> int:
174
+ """
175
+ Unsubscribe all subscriptions for a client.
176
+
177
+ Args:
178
+ client_id: Client to unsubscribe
179
+
180
+ Returns:
181
+ Number of subscriptions removed
182
+ """
183
+ if client_id not in self.client_subscriptions:
184
+ return 0
185
+
186
+ subscription_ids = self.client_subscriptions[client_id].copy()
187
+ count = 0
188
+
189
+ for subscription_id in subscription_ids:
190
+ if await self.unsubscribe(subscription_id):
191
+ count += 1
192
+
193
+ # Remove handler
194
+ if client_id in self.update_handlers:
195
+ del self.update_handlers[client_id]
196
+
197
+ return count
198
+
199
+ async def notify_update(
200
+ self,
201
+ resource_uri: str,
202
+ event_type: SubscriptionEvent,
203
+ data: Any,
204
+ metadata: Optional[Dict[str, Any]] = None
205
+ ):
206
+ """
207
+ Notify subscribers of a resource update.
208
+
209
+ Args:
210
+ resource_uri: URI of updated resource
211
+ event_type: Type of event
212
+ data: Update data
213
+ metadata: Additional metadata
214
+ """
215
+ # Create update event
216
+ update = ResourceUpdate(
217
+ resource_uri=resource_uri,
218
+ event_type=event_type,
219
+ data=data,
220
+ timestamp=datetime.now(),
221
+ metadata=metadata or {}
222
+ )
223
+
224
+ # Sign update if MACAW available
225
+ if self.server and hasattr(self.server, 'dual_signing'):
226
+ signed = self.server.dual_signing.sign_message(update.to_dict())
227
+ if signed:
228
+ update.signature = signed.signature
229
+
230
+ # Find matching subscriptions
231
+ matching_subs = self._find_matching_subscriptions(resource_uri, event_type)
232
+
233
+ # Notify each subscriber
234
+ for subscription_id in matching_subs:
235
+ subscription = self.subscriptions[subscription_id]
236
+ await self._send_update(subscription, update)
237
+
238
+ def _find_matching_subscriptions(
239
+ self,
240
+ resource_uri: str,
241
+ event_type: SubscriptionEvent
242
+ ) -> List[str]:
243
+ """
244
+ Find subscriptions that match a resource update.
245
+
246
+ Args:
247
+ resource_uri: URI of updated resource
248
+ event_type: Type of event
249
+
250
+ Returns:
251
+ List of matching subscription IDs
252
+ """
253
+ matching = []
254
+
255
+ # Check exact matches
256
+ if resource_uri in self.resource_subscriptions:
257
+ for sub_id in self.resource_subscriptions[resource_uri]:
258
+ subscription = self.subscriptions[sub_id]
259
+ if subscription.matches_event(event_type):
260
+ matching.append(sub_id)
261
+
262
+ # Check pattern matches (simplified - real implementation would do glob matching)
263
+ for pattern, sub_ids in self.resource_subscriptions.items():
264
+ if "*" in pattern:
265
+ # Simple wildcard matching
266
+ pattern_base = pattern.replace("*", "")
267
+ if resource_uri.startswith(pattern_base):
268
+ for sub_id in sub_ids:
269
+ subscription = self.subscriptions[sub_id]
270
+ if subscription.matches_event(event_type) and sub_id not in matching:
271
+ matching.append(sub_id)
272
+
273
+ return matching
274
+
275
+ async def _send_update(self, subscription: Subscription, update: ResourceUpdate):
276
+ """
277
+ Send an update to a subscriber.
278
+
279
+ Args:
280
+ subscription: Subscription to notify
281
+ update: Update to send
282
+ """
283
+ try:
284
+ # Update subscription timestamp
285
+ subscription.last_update = datetime.now()
286
+
287
+ # Get handler for client
288
+ handler = self.update_handlers.get(subscription.client_id)
289
+
290
+ if handler:
291
+ # Call handler with update
292
+ await handler(update.to_dict())
293
+ logger.debug(f"Sent update to {subscription.client_id} for {update.resource_uri}")
294
+ else:
295
+ logger.warning(f"No handler for client {subscription.client_id}")
296
+
297
+ except Exception as e:
298
+ logger.error(f"Failed to send update to {subscription.client_id}: {e}")
299
+
300
+ def get_subscriptions(self, client_id: Optional[str] = None) -> List[Subscription]:
301
+ """
302
+ Get subscriptions, optionally filtered by client.
303
+
304
+ Args:
305
+ client_id: Optional client ID to filter by
306
+
307
+ Returns:
308
+ List of subscriptions
309
+ """
310
+ if client_id:
311
+ sub_ids = self.client_subscriptions.get(client_id, [])
312
+ return [self.subscriptions[sid] for sid in sub_ids]
313
+ else:
314
+ return list(self.subscriptions.values())
315
+
316
+
317
+ class SubscriptionClient:
318
+ """
319
+ Client-side subscription handler for SecureMCP.
320
+
321
+ Manages subscriptions and handles incoming updates.
322
+ """
323
+
324
+ def __init__(self, client):
325
+ """
326
+ Initialize subscription client.
327
+
328
+ Args:
329
+ client: SecureMCP Client instance
330
+ """
331
+ self.client = client
332
+ self.subscriptions: Dict[str, Subscription] = {}
333
+ self.update_callbacks: Dict[str, Callable] = {} # resource_uri -> callback
334
+
335
+ logger.info(f"SubscriptionClient initialized for {client.client_id}")
336
+
337
+ async def subscribe(
338
+ self,
339
+ resource_uri: str,
340
+ callback: Callable,
341
+ event_types: Optional[List[SubscriptionEvent]] = None
342
+ ) -> str:
343
+ """
344
+ Subscribe to resource updates.
345
+
346
+ Args:
347
+ resource_uri: Resource to subscribe to
348
+ callback: Function to call on updates
349
+ event_types: Types of events to subscribe to
350
+
351
+ Returns:
352
+ Subscription ID
353
+ """
354
+ # Store callback
355
+ self.update_callbacks[resource_uri] = callback
356
+
357
+ # Send subscription request to server
358
+ if hasattr(self.client, 'stdio_client'):
359
+ # STDIO transport
360
+ result = await self.client.stdio_client.send_request(
361
+ "resources/subscribe",
362
+ {
363
+ "uri": resource_uri,
364
+ "client_id": self.client.client_id,
365
+ "event_types": [e.value for e in (event_types or [SubscriptionEvent.UPDATED])]
366
+ }
367
+ )
368
+ subscription_id = result.get("subscription_id")
369
+ else:
370
+ # Would implement for other transports
371
+ subscription_id = f"sub-{uuid.uuid4().hex[:8]}"
372
+
373
+ # Store subscription
374
+ subscription = Subscription(
375
+ subscription_id=subscription_id,
376
+ client_id=self.client.client_id,
377
+ resource_uri=resource_uri,
378
+ created_at=datetime.now(),
379
+ event_types=set(event_types) if event_types else {SubscriptionEvent.UPDATED}
380
+ )
381
+ self.subscriptions[subscription_id] = subscription
382
+
383
+ logger.info(f"Subscribed to {resource_uri}: {subscription_id}")
384
+ return subscription_id
385
+
386
+ async def handle_update(self, update_data: Dict[str, Any]):
387
+ """
388
+ Handle an incoming resource update.
389
+
390
+ Args:
391
+ update_data: Update data from server
392
+ """
393
+ resource_uri = update_data.get("resource_uri")
394
+
395
+ # Verify signature if present
396
+ if "signature" in update_data and hasattr(self.client, 'dual_signing'):
397
+ # Would verify server signature here
398
+ logger.debug(f"Received signed update for {resource_uri}")
399
+
400
+ # Find callback
401
+ callback = self.update_callbacks.get(resource_uri)
402
+
403
+ # Also check pattern matches
404
+ if not callback:
405
+ for pattern, cb in self.update_callbacks.items():
406
+ if "*" in pattern:
407
+ pattern_base = pattern.replace("*", "")
408
+ if resource_uri.startswith(pattern_base):
409
+ callback = cb
410
+ break
411
+
412
+ if callback:
413
+ try:
414
+ await callback(update_data)
415
+ except Exception as e:
416
+ logger.error(f"Callback error for {resource_uri}: {e}")
417
+ else:
418
+ logger.warning(f"No callback for resource update: {resource_uri}")
419
+
420
+ async def unsubscribe(self, subscription_id: str) -> bool:
421
+ """
422
+ Unsubscribe from resource updates.
423
+
424
+ Args:
425
+ subscription_id: Subscription to cancel
426
+
427
+ Returns:
428
+ True if unsubscribed successfully
429
+ """
430
+ if subscription_id not in self.subscriptions:
431
+ return False
432
+
433
+ subscription = self.subscriptions[subscription_id]
434
+
435
+ # Send unsubscribe request to server
436
+ if hasattr(self.client, 'stdio_client'):
437
+ await self.client.stdio_client.send_request(
438
+ "resources/unsubscribe",
439
+ {"subscription_id": subscription_id}
440
+ )
441
+
442
+ # Remove callback
443
+ if subscription.resource_uri in self.update_callbacks:
444
+ del self.update_callbacks[subscription.resource_uri]
445
+
446
+ # Remove subscription
447
+ del self.subscriptions[subscription_id]
448
+
449
+ logger.info(f"Unsubscribed: {subscription_id}")
450
+ return True