@macaw-ai/client 0.0.0-stage → 0.9.11

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 +422 -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,840 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SecureAnthropic - MACAW-protected Anthropic Claude wrapper.
4
+
5
+ Drop-in replacement for Anthropic client with MACAW security.
6
+ Uses MAPL-compliant tool names: tool:<app_name>/generate, tool:<app_name>/complete.
7
+
8
+ Supports three usage paths:
9
+ 1. Direct on service: client.messages.create() - simplest, app-level identity
10
+ 2. bind_to_user: service.bind_to_user(user_client) - per-user identity for SaaS
11
+ 3. invoke_tool: user.invoke_tool("tool:xxx/generate", ...) - explicit A2A control
12
+
13
+ Architecture (service mode):
14
+ - macaw_client: Primary client owning LLM operations (tool:{app_name}/generate, etc.)
15
+ - _tools_client: Internal client owning user-registered tools (tool:{app_name}-tools/*, not externally reachable)
16
+ - server_id: Points to macaw_client (the primary interface)
17
+ - Tool isolation: Only macaw_client can invoke tools on _tools_client
18
+
19
+ Install with: pip install -e /path/to/secureAI
20
+ """
21
+
22
+ import os
23
+ import json
24
+ import logging
25
+ import inspect
26
+ from typing import Dict, Any, Optional, List
27
+
28
+ from anthropic import Anthropic
29
+ from anthropic.types import Message, ContentBlock, TextBlock, Usage
30
+ from macaw_client import MACAWClient
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class _StreamContextManager:
36
+ """
37
+ Context manager that provides Anthropic-compatible streaming interface.
38
+
39
+ Usage:
40
+ with client.messages.stream(...) as stream:
41
+ for text in stream.text_stream:
42
+ print(text, end="")
43
+ """
44
+
45
+ def __init__(self, iterator):
46
+ self._iterator = iterator
47
+ self._text_chunks = []
48
+
49
+ def __enter__(self):
50
+ return self
51
+
52
+ def __exit__(self, exc_type, exc_val, exc_tb):
53
+ # Clean up iterator if needed
54
+ pass
55
+
56
+ @property
57
+ def text_stream(self):
58
+ """Yield just the text content from streaming chunks."""
59
+ for chunk in self._iterator:
60
+ if isinstance(chunk, dict):
61
+ # Handle content_block_delta events
62
+ if chunk.get('type') == 'content_block_delta':
63
+ delta = chunk.get('delta', {})
64
+ text = delta.get('text', '')
65
+ if text:
66
+ yield text
67
+ elif hasattr(chunk, 'delta') and hasattr(chunk.delta, 'text'):
68
+ # Handle raw event objects
69
+ if chunk.delta.text:
70
+ yield chunk.delta.text
71
+
72
+ def __iter__(self):
73
+ """Iterate over raw chunks."""
74
+ return iter(self._iterator)
75
+
76
+
77
+ class SecureAnthropic:
78
+ """
79
+ Drop-in replacement for Anthropic Claude client with MACAW security.
80
+
81
+ This version uses a two-client architecture for tool isolation:
82
+ - macaw_client: Primary, owns LLM operations (tool:{app_name}/generate, etc.)
83
+ - _tools_client: Internal, owns user-registered tools (not externally reachable)
84
+
85
+ server_id points to macaw_client, making it the primary external interface.
86
+ Tools are isolated: only macaw_client can invoke tools on _tools_client.
87
+
88
+ Supports two modes:
89
+ 1. Service mode (default): Creates both clients, handles Claude calls
90
+ 2. User mode (jwt_token provided): Creates user agent with identity for direct calls
91
+
92
+ For multi-user scenarios, use service mode + bind_to_user() to create per-user wrappers.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ api_key: str = None,
98
+ app_name: str = None,
99
+ intent_policy: Optional[Dict[str, Any]] = None,
100
+ # User mode parameters
101
+ jwt_token: str = None,
102
+ user_name: str = None
103
+ ):
104
+ """
105
+ Initialize SecureAnthropic wrapper.
106
+
107
+ Args:
108
+ api_key: Anthropic API key (or from env)
109
+ app_name: Application name for registration
110
+ intent_policy: Application-defined MACAW intent policy
111
+ jwt_token: If provided, creates user agent with this identity (user mode)
112
+ user_name: Optional user name for user mode
113
+ """
114
+ # Get API key
115
+ self.api_key = api_key or os.environ.get('ANTHROPIC_API_KEY')
116
+ if not self.api_key:
117
+ raise ValueError("Anthropic API key required")
118
+
119
+ # Real Claude client
120
+ self.claude_client = Anthropic(api_key=self.api_key)
121
+
122
+ # Application identity
123
+ self.app_name = app_name or "secure-claude-app"
124
+
125
+ # Determine mode based on jwt_token
126
+ self._mode = "user" if jwt_token else "service"
127
+ self._jwt_token = jwt_token
128
+ self._user_name = user_name
129
+
130
+ # User-registered tools (Claude can call these)
131
+ self.user_tools = {}
132
+
133
+ # Auto-discovered tools cache
134
+ self._discovered_tools = {}
135
+ self._tools_registered = False
136
+
137
+ # Application-provided intent policy (no defaults!)
138
+ self.intent_policy = intent_policy or {}
139
+
140
+ if self._mode == "service":
141
+ # SERVICE MODE: Two-client architecture for tool isolation
142
+ # - macaw_client: Primary, owns LLM operations (externally reachable)
143
+ # - _tools_client: Internal, owns user tools (not externally reachable)
144
+
145
+ # Tools client name (for trace clarity)
146
+ self._tools_name = f"{self.app_name}-tools"
147
+
148
+ # LLM tools on the primary client
149
+ llm_tools = {
150
+ f"tool:{self.app_name}/generate": {
151
+ "handler": self._handle_generate,
152
+ "prompts": ["messages"] # messages param is a prompt
153
+ },
154
+ f"tool:{self.app_name}/complete": {
155
+ "handler": self._handle_complete,
156
+ "prompts": ["prompt"] # prompt param is a prompt
157
+ }
158
+ }
159
+
160
+ # Primary client - owns LLM operations, externally reachable via server_id
161
+ self.macaw_client = MACAWClient(
162
+ app_name=self.app_name,
163
+ app_version="1.0.0",
164
+ intent_policy=self.intent_policy,
165
+ tools=llm_tools
166
+ )
167
+
168
+ # Internal tools client - owns user-registered tools, NOT externally reachable
169
+ self._tools_client = MACAWClient(
170
+ app_name=self._tools_name,
171
+ app_version="1.0.0",
172
+ intent_policy=self.intent_policy,
173
+ tools={} # User tools added via register_tool
174
+ )
175
+ else:
176
+ # USER MODE: Create user agent with identity
177
+ self._tools_name = None
178
+ self._tools_client = None
179
+ self.tools = {}
180
+
181
+ self.macaw_client = MACAWClient(
182
+ user_name=user_name,
183
+ iam_token=jwt_token,
184
+ agent_type="user",
185
+ app_name=self.app_name,
186
+ app_version="1.0.0",
187
+ intent_policy=self.intent_policy or {
188
+ "purpose": f"Claude access for {user_name or 'user'}"
189
+ }
190
+ )
191
+
192
+ # Register with LocalAgent and get server ID(s)
193
+ if self._mode == "service":
194
+ # Register both clients
195
+ if self.macaw_client.register() and self._tools_client.register():
196
+ self.server_id = self.macaw_client.agent_id # Primary interface!
197
+ self._tools_server_id = self._tools_client.agent_id # Internal, not exposed
198
+ logger.info(f"SecureAnthropic registered (two-client mode):")
199
+ logger.info(f" Primary (LLM): {self.server_id}")
200
+ logger.info(f" Internal (tools): {self._tools_server_id}")
201
+ else:
202
+ raise RuntimeError("Failed to register with MACAW LocalAgent")
203
+ else:
204
+ # User mode - single client
205
+ if self.macaw_client.register():
206
+ self.server_id = self.macaw_client.agent_id
207
+ logger.info(f"SecureAnthropic registered as: {self.server_id} (mode: {self._mode})")
208
+ else:
209
+ raise RuntimeError("Failed to register with MACAW LocalAgent")
210
+
211
+ # Mimic Anthropic API structure
212
+ # MACAW-protected namespaces (route through PEP)
213
+ self.messages = self._MessagesNamespace(self)
214
+ self.completions = self._CompletionsNamespace(self)
215
+
216
+ # =========================================================================
217
+ # Pass-through properties for non-MACAW-protected APIs
218
+ # These delegate directly to the underlying Anthropic client
219
+ # =========================================================================
220
+
221
+ @property
222
+ def beta(self):
223
+ """
224
+ Pass-through to Anthropic beta API (message batches).
225
+
226
+ Not MACAW-protected - batch operations use service's API key directly.
227
+ """
228
+ return self.claude_client.beta
229
+
230
+ def count_tokens(self, *args, **kwargs):
231
+ """
232
+ Pass-through to Anthropic count_tokens method.
233
+
234
+ Not MACAW-protected - token counting uses service's API key directly.
235
+ """
236
+ return self.claude_client.count_tokens(*args, **kwargs)
237
+
238
+ def bind_to_user(self, user_client: 'MACAWClient') -> 'BoundSecureAnthropic':
239
+ """
240
+ Bind this SecureAnthropic service to a user's MACAW client.
241
+
242
+ Creates a lightweight wrapper that routes calls through the user's
243
+ client to this service, enabling per-user identity and policy enforcement.
244
+
245
+ Only valid in service mode.
246
+
247
+ Security validations:
248
+ - Service must be in service mode
249
+ - User client must be a valid MACAWClient instance
250
+ - User client must be registered with LocalAgent
251
+ - User client should be a "user" type agent
252
+
253
+ Args:
254
+ user_client: A registered MACAWClient with user identity
255
+
256
+ Returns:
257
+ BoundSecureAnthropic wrapper for this user
258
+
259
+ Raises:
260
+ ValueError: If service not in service mode or client validation fails
261
+ """
262
+ if self._mode != "service":
263
+ raise ValueError("bind_to_user() only valid for service-mode SecureAnthropic")
264
+
265
+ # Validate user_client is a MACAWClient instance
266
+ if not hasattr(user_client, 'agent_id') or not hasattr(user_client, 'invoke_tool'):
267
+ raise ValueError("bind_to_user() requires a valid MACAWClient instance")
268
+
269
+ # Validate user_client is registered
270
+ if not getattr(user_client, 'registered', False):
271
+ raise ValueError("bind_to_user() requires a registered MACAWClient. Call register() first.")
272
+
273
+ # Validate agent_type is "user" (warning only, not blocking)
274
+ agent_type = getattr(user_client, 'agent_type', None)
275
+ if agent_type and agent_type != "user":
276
+ logger.warning(f"bind_to_user() called with agent_type='{agent_type}' (expected 'user'). "
277
+ f"User identity and policy enforcement may not work as expected.")
278
+
279
+ return BoundSecureAnthropic(self, user_client)
280
+
281
+ def register_tool(self, name: str, handler: callable) -> 'SecureAnthropic':
282
+ """
283
+ Register a tool that Claude can call.
284
+
285
+ Tools are registered on _tools_client (internal, not externally reachable).
286
+ This provides tool isolation - only the LLM can invoke these tools.
287
+
288
+ Args:
289
+ name: Tool name (must match Claude function name)
290
+ handler: Function to handle tool execution
291
+
292
+ Returns:
293
+ Self for chaining
294
+ """
295
+ # Store with MAPL-compliant name using tools client's app_name
296
+ mapl_name = f"tool:{self._tools_name}/{name}"
297
+
298
+ # Wrap handler to accept params dict and unpack as kwargs
299
+ def wrapped_handler(params):
300
+ return handler(**params)
301
+
302
+ self.user_tools[name] = wrapped_handler # Store by simple name for lookup
303
+ self.user_tools[mapl_name] = wrapped_handler # Also store by MAPL name
304
+
305
+ # Register with _tools_client (internal) using MAPL name
306
+ self._tools_client.register_tool(mapl_name, wrapped_handler)
307
+
308
+ logger.info(f"Registered tool: {name} -> {mapl_name}")
309
+ return self
310
+
311
+ def _auto_discover_tools(self, tools_metadata):
312
+ """
313
+ Auto-discover tool implementations from caller's context.
314
+
315
+ Args:
316
+ tools_metadata: List of tool definitions from Claude call
317
+ """
318
+ if self._tools_registered:
319
+ return # Already discovered
320
+
321
+ # Get caller's frame (need to go back through the call stack)
322
+ frame = inspect.currentframe()
323
+ caller_frame = frame.f_back.f_back.f_back
324
+ if not caller_frame:
325
+ logger.warning("Could not access caller frame for auto-discovery")
326
+ return
327
+
328
+ caller_globals = caller_frame.f_globals
329
+ caller_locals = caller_frame.f_locals
330
+
331
+ # Combine locals and globals for search
332
+ search_scope = {**caller_globals, **caller_locals}
333
+
334
+ # Discover implementations for each tool
335
+ for tool_def in tools_metadata:
336
+ func_name = tool_def.get("name")
337
+
338
+ # Use MAPL-compliant name with tools client: tool:<app_name>-tools/<func_name>
339
+ mapl_name = f"tool:{self._tools_name}/{func_name}"
340
+ if func_name and func_name not in self.user_tools:
341
+ # Look for function in caller's scope
342
+ if func_name in search_scope:
343
+ func = search_scope[func_name]
344
+ if callable(func):
345
+ logger.info(f"Auto-discovered tool: {func_name} -> {mapl_name}")
346
+ self._discovered_tools[func_name] = func
347
+ self.user_tools[func_name] = func
348
+ self.user_tools[mapl_name] = func
349
+ else:
350
+ logger.warning(f"Tool '{func_name}' not found in caller's scope")
351
+
352
+ # Sync all discovered tools with _tools_client (internal)
353
+ if self._discovered_tools:
354
+ # Register each discovered tool with _tools_client
355
+ for func_name, func in self._discovered_tools.items():
356
+ mapl_name = f"tool:{self._tools_name}/{func_name}"
357
+ # Create wrapper handler that accepts dict parameter
358
+ def create_handler(tool_func):
359
+ def handler(params):
360
+ return tool_func(**params)
361
+ return handler
362
+ self._tools_client.register_tool(mapl_name, create_handler(func))
363
+ self._tools_registered = True
364
+ logger.info(f"Auto-registered {len(self._discovered_tools)} tools with _tools_client")
365
+
366
+ def _handle_generate(self, params: Dict[str, Any]) -> Dict[str, Any]:
367
+ """
368
+ Handle 'generate' tool (messages creation).
369
+
370
+ This runs on macaw_client. Tool callbacks route to _tools_client (internal).
371
+ Returns a serializable dict for MACAW protocol, or an iterator for streaming.
372
+ """
373
+ try:
374
+ # Check if streaming is requested
375
+ is_streaming = params.get('stream', False)
376
+
377
+ if is_streaming:
378
+ # Streaming mode: return iterator
379
+ return self._stream_generate(params)
380
+
381
+ # Non-streaming mode: existing behavior
382
+ # Call real Claude API (params are clean - no MACAW internals)
383
+ response = self.claude_client.messages.create(**params)
384
+
385
+ # Check if Claude wants to call tools
386
+ if hasattr(response, 'content') and response.content:
387
+ for content_block in response.content:
388
+ if content_block.type == 'tool_use':
389
+ func_name = content_block.name
390
+ tool_args = content_block.input
391
+ # Convert to MAPL-compliant name using tools client: tool:<app_name>-tools/<func_name>
392
+ mapl_name = f"tool:{self._tools_name}/{func_name}"
393
+ logger.info(f"Claude requested tool use: {func_name} -> {mapl_name}")
394
+
395
+ # Check if we have this tool (by simple name or MAPL name)
396
+ if func_name not in self.user_tools and mapl_name not in self.user_tools:
397
+ result = {
398
+ 'error': f"Tool not found: {mapl_name}",
399
+ 'available_tools': list(self.user_tools.keys())
400
+ }
401
+ else:
402
+ # Invoke tool through MACAW (goes through PEP!)
403
+ # Route from macaw_client to _tools_client (internal)
404
+ # This provides tool isolation: only LLM can call tools
405
+ try:
406
+ result = self.macaw_client.invoke_tool(
407
+ tool_name=mapl_name,
408
+ parameters=tool_args,
409
+ target_agent=self._tools_server_id # Internal tools client
410
+ )
411
+ logger.info(f"Tool {func_name} executed successfully via _tools_client")
412
+ except Exception as e:
413
+ error_msg = str(e)
414
+ # Check if MACAW blocked it
415
+ if 'denied' in error_msg.lower() or 'policy' in error_msg.lower():
416
+ logger.warning(f"MACAW blocked tool {mapl_name}: {error_msg}")
417
+ result = {
418
+ 'error': 'Access denied by security policy',
419
+ 'tool': mapl_name,
420
+ 'reason': 'Policy violation'
421
+ }
422
+ else:
423
+ result = {'error': str(e)}
424
+
425
+ # Build messages for continuation
426
+ messages = list(params.get('messages', []))
427
+
428
+ # Add assistant message with tool use (use original func_name for Claude)
429
+ messages.append({
430
+ "role": "assistant",
431
+ "content": [{"type": "tool_use", "id": content_block.id,
432
+ "name": func_name, "input": tool_args}]
433
+ })
434
+
435
+ # Add tool result
436
+ messages.append({
437
+ "role": "user",
438
+ "content": [{
439
+ "type": "tool_result",
440
+ "tool_use_id": content_block.id,
441
+ "content": json.dumps(result)
442
+ }]
443
+ })
444
+
445
+ # Continue conversation with tool results
446
+ final_params = params.copy()
447
+ final_params['messages'] = messages
448
+
449
+ # Remove tools to prevent infinite recursion
450
+ if 'tools' in final_params:
451
+ del final_params['tools']
452
+
453
+ final_response = self.claude_client.messages.create(**final_params)
454
+
455
+ # Convert to serializable dict for MACAW protocol
456
+ return self._response_to_dict(final_response)
457
+
458
+ # No tool calls, convert response to dict
459
+ return self._response_to_dict(response)
460
+
461
+ except Exception as e:
462
+ logger.error(f"Error in generate handler: {e}")
463
+ return {'error': str(e)}
464
+
465
+ def _stream_generate(self, params: Dict[str, Any]):
466
+ """
467
+ Handle streaming message creation.
468
+
469
+ Returns an iterator that yields chunks from the Anthropic streaming API.
470
+ Each chunk is converted to a serializable dict.
471
+ """
472
+ try:
473
+ # Call Anthropic with streaming
474
+ with self.claude_client.messages.stream(**{k: v for k, v in params.items() if k != 'stream'}) as stream:
475
+ for event in stream:
476
+ # Convert event to serializable dict
477
+ if hasattr(event, 'type'):
478
+ chunk_dict = {
479
+ 'type': event.type,
480
+ }
481
+ # Handle different event types
482
+ if event.type == 'content_block_delta':
483
+ if hasattr(event, 'delta') and hasattr(event.delta, 'text'):
484
+ chunk_dict['delta'] = {'text': event.delta.text}
485
+ if hasattr(event, 'index'):
486
+ chunk_dict['index'] = event.index
487
+ elif event.type == 'message_start':
488
+ if hasattr(event, 'message'):
489
+ chunk_dict['message'] = {
490
+ 'id': event.message.id,
491
+ 'model': event.message.model,
492
+ 'role': event.message.role,
493
+ }
494
+ elif event.type == 'message_delta':
495
+ if hasattr(event, 'delta'):
496
+ chunk_dict['delta'] = {}
497
+ if hasattr(event.delta, 'stop_reason'):
498
+ chunk_dict['delta']['stop_reason'] = event.delta.stop_reason
499
+ if hasattr(event, 'usage'):
500
+ chunk_dict['usage'] = {
501
+ 'output_tokens': event.usage.output_tokens
502
+ }
503
+ elif event.type == 'message_stop':
504
+ pass # Just mark end of stream
505
+
506
+ yield chunk_dict
507
+
508
+ except Exception as e:
509
+ logger.error(f"Error in streaming generate: {e}")
510
+ # Yield error as final chunk
511
+ yield {'error': str(e)}
512
+
513
+ def _handle_complete(self, params: Dict[str, Any]) -> Dict[str, Any]:
514
+ """
515
+ Handle 'complete' tool (legacy completions).
516
+
517
+ Returns a serializable dict for MACAW protocol.
518
+ """
519
+ try:
520
+ response = self.claude_client.completions.create(**params)
521
+ return {
522
+ 'id': getattr(response, 'id', None),
523
+ 'type': 'completion',
524
+ 'completion': response.completion,
525
+ 'model': getattr(response, 'model', None),
526
+ 'stop_reason': getattr(response, 'stop_reason', None)
527
+ }
528
+ except Exception as e:
529
+ logger.error(f"Error in complete handler: {e}")
530
+ return {'error': str(e)}
531
+
532
+ def _response_to_dict(self, response) -> Dict[str, Any]:
533
+ """Convert Anthropic Message response to serializable dict."""
534
+ result = {
535
+ 'id': response.id,
536
+ 'type': response.type,
537
+ 'role': response.role,
538
+ 'model': response.model,
539
+ 'content': [],
540
+ 'stop_reason': response.stop_reason,
541
+ 'stop_sequence': response.stop_sequence,
542
+ 'usage': {
543
+ 'input_tokens': response.usage.input_tokens,
544
+ 'output_tokens': response.usage.output_tokens
545
+ } if response.usage else None
546
+ }
547
+
548
+ # Convert content blocks
549
+ for block in response.content:
550
+ if hasattr(block, 'text'):
551
+ result['content'].append({
552
+ 'type': 'text',
553
+ 'text': block.text
554
+ })
555
+ elif hasattr(block, 'type') and block.type == 'tool_use':
556
+ result['content'].append({
557
+ 'type': 'tool_use',
558
+ 'id': block.id,
559
+ 'name': block.name,
560
+ 'input': block.input
561
+ })
562
+
563
+ return result
564
+
565
+ def _dict_to_message(self, data: Dict[str, Any]) -> Message:
566
+ """Reconstruct Anthropic Message from dict."""
567
+ # Build content blocks
568
+ content = []
569
+ for block_data in data.get('content', []):
570
+ if block_data.get('type') == 'text':
571
+ content.append(TextBlock(type='text', text=block_data['text']))
572
+ # Add other block types as needed
573
+
574
+ # Build usage
575
+ usage = None
576
+ if data.get('usage'):
577
+ usage = Usage(
578
+ input_tokens=data['usage']['input_tokens'],
579
+ output_tokens=data['usage']['output_tokens']
580
+ )
581
+
582
+ return Message(
583
+ id=data['id'],
584
+ type=data.get('type', 'message'),
585
+ role=data.get('role', 'assistant'),
586
+ model=data['model'],
587
+ content=content,
588
+ stop_reason=data.get('stop_reason'),
589
+ stop_sequence=data.get('stop_sequence'),
590
+ usage=usage
591
+ )
592
+
593
+ # Namespace classes to mimic Anthropic API structure
594
+ class _MessagesNamespace:
595
+ def __init__(self, parent):
596
+ self.parent = parent
597
+
598
+ def create(self, **kwargs):
599
+ """
600
+ Create message - routes through MACAW.
601
+
602
+ This mimics Anthropic's API but goes through PEP!
603
+ Adapter creates authenticated prompts based on its own prompts declaration.
604
+ Supports streaming when stream=True is passed.
605
+ """
606
+ # Auto-discover tools if provided
607
+ tools = kwargs.get('tools')
608
+ if tools:
609
+ self.parent._auto_discover_tools(tools)
610
+
611
+ tool_name = f"tool:{self.parent.app_name}/generate"
612
+ is_streaming = kwargs.get('stream', False)
613
+
614
+ # Route through MACAW's generate resource
615
+ # MAPL-compliant: tool:<service>/generate
616
+ # Authenticated prompts are auto-created by invoke_tool() based on registry
617
+ result = self.parent.macaw_client.invoke_tool(
618
+ tool_name=tool_name,
619
+ parameters=kwargs,
620
+ target_agent=self.parent.server_id, # Route to ourselves!
621
+ stream=is_streaming # Explicit streaming flag
622
+ )
623
+
624
+ if is_streaming:
625
+ # Streaming mode: result is a StreamingResultIterator, wrap in Anthropic types
626
+ return self._wrap_streaming_response(result)
627
+
628
+ # Handle errors
629
+ if isinstance(result, dict) and 'error' in result:
630
+ raise Exception(f"MACAW error: {result['error']}")
631
+
632
+ # Reconstruct Message from dict
633
+ return self.parent._dict_to_message(result)
634
+
635
+ def _wrap_streaming_response(self, iterator):
636
+ """Wrap streaming chunks for Anthropic-compatible iteration."""
637
+ for chunk in iterator:
638
+ if isinstance(chunk, dict):
639
+ if 'error' in chunk:
640
+ raise Exception(f"MACAW streaming error: {chunk['error']}")
641
+ yield chunk
642
+ else:
643
+ yield chunk
644
+
645
+ def stream(self, **kwargs):
646
+ """
647
+ Stream message creation - context manager style.
648
+
649
+ Usage:
650
+ with client.messages.stream(...) as stream:
651
+ for text in stream.text_stream:
652
+ print(text, end="")
653
+ """
654
+ # Use create with stream=True, wrap in context manager
655
+ kwargs['stream'] = True
656
+ return _StreamContextManager(self.create(**kwargs))
657
+
658
+ class _CompletionsNamespace:
659
+ def __init__(self, parent):
660
+ self.parent = parent
661
+
662
+ def create(self, **kwargs):
663
+ """Create completion - routes through MACAW."""
664
+ tool_name = f"tool:{self.parent.app_name}/complete"
665
+
666
+ # MAPL-compliant: tool:<service>/complete
667
+ # Authenticated prompts are auto-created by invoke_tool() based on registry
668
+ result = self.parent.macaw_client.invoke_tool(
669
+ tool_name=tool_name,
670
+ parameters=kwargs,
671
+ target_agent=self.parent.server_id # Route to ourselves!
672
+ )
673
+
674
+ # Handle errors
675
+ if isinstance(result, dict) and 'error' in result:
676
+ raise Exception(f"MACAW error: {result['error']}")
677
+
678
+ # Return raw completion dict (legacy API)
679
+ return result
680
+
681
+
682
+ class BoundSecureAnthropic:
683
+ """
684
+ Per-user wrapper for SecureAnthropic service.
685
+
686
+ Created via SecureAnthropic.bind_to_user(user_client).
687
+ Routes all calls through the user's MACAWClient to the service,
688
+ enabling per-user identity and policy enforcement.
689
+
690
+ Authenticated prompts are auto-created by invoke_tool based on
691
+ the service's prompts declaration in the registry.
692
+
693
+ Call unbind() to invalidate this binding when done.
694
+ """
695
+
696
+ def __init__(self, service: SecureAnthropic, user_client: 'MACAWClient'):
697
+ """
698
+ Initialize bound wrapper.
699
+
700
+ Args:
701
+ service: The shared SecureAnthropic service (must be in service mode)
702
+ user_client: User's registered MACAWClient with identity
703
+ """
704
+ self._service = service
705
+ self._user_client = user_client
706
+ self._bound = True
707
+
708
+ # Create Anthropic-compatible API namespaces
709
+ self.messages = self._MessagesNamespace(self)
710
+ self.completions = self._CompletionsNamespace(self)
711
+
712
+ @property
713
+ def service(self) -> SecureAnthropic:
714
+ """Get the bound service (raises if unbound)."""
715
+ if not self._bound:
716
+ raise RuntimeError("BoundSecureAnthropic has been unbound. Create a new binding with bind_to_user().")
717
+ return self._service
718
+
719
+ @property
720
+ def user_client(self) -> 'MACAWClient':
721
+ """Get the bound user client (raises if unbound)."""
722
+ if not self._bound:
723
+ raise RuntimeError("BoundSecureAnthropic has been unbound. Create a new binding with bind_to_user().")
724
+ return self._user_client
725
+
726
+ def unbind(self):
727
+ """
728
+ Unbind this wrapper, invalidating all future calls.
729
+
730
+ After unbinding:
731
+ - All API calls will raise RuntimeError
732
+ - References to service and user_client are cleared
733
+ - A new binding must be created via bind_to_user()
734
+
735
+ This is useful for cleanup and ensuring bindings are not reused
736
+ after a user session ends.
737
+ """
738
+ if not self._bound:
739
+ logger.warning("BoundSecureAnthropic.unbind() called on already unbound instance")
740
+ return
741
+
742
+ logger.info(f"Unbinding SecureAnthropic from user {self._user_client.agent_id}")
743
+ self._bound = False
744
+ self._service = None
745
+ self._user_client = None
746
+
747
+ @property
748
+ def is_bound(self) -> bool:
749
+ """Check if this wrapper is still bound."""
750
+ return self._bound
751
+
752
+ # =========================================================================
753
+ # Pass-through properties for non-MACAW-protected APIs
754
+ # These delegate to the service's underlying Anthropic client
755
+ # =========================================================================
756
+
757
+ @property
758
+ def beta(self):
759
+ """Pass-through to Anthropic beta API (message batches) via service."""
760
+ return self.service.claude_client.beta
761
+
762
+ def count_tokens(self, *args, **kwargs):
763
+ """Pass-through to Anthropic count_tokens method via service."""
764
+ return self.service.claude_client.count_tokens(*args, **kwargs)
765
+
766
+ class _MessagesNamespace:
767
+ def __init__(self, bound: 'BoundSecureAnthropic'):
768
+ self.bound = bound
769
+
770
+ def create(self, **kwargs):
771
+ """
772
+ Create message via user's client -> service.
773
+
774
+ Authenticated prompts are auto-created by invoke_tool.
775
+ Supports streaming with stream=True parameter.
776
+ """
777
+ tool_name = f"tool:{self.bound.service.app_name}/generate"
778
+ is_streaming = kwargs.get('stream', False)
779
+
780
+ # Route through user's client (auto-creates auth prompts!)
781
+ result = self.bound.user_client.invoke_tool(
782
+ tool_name=tool_name,
783
+ parameters=kwargs,
784
+ target_agent=self.bound.service.server_id,
785
+ stream=is_streaming # Explicit streaming flag
786
+ )
787
+
788
+ if is_streaming:
789
+ # Streaming mode: result is a StreamingResultIterator, wrap for iteration
790
+ return self._wrap_streaming_response(result)
791
+
792
+ # Handle errors
793
+ if isinstance(result, dict) and 'error' in result:
794
+ raise Exception(f"MACAW error: {result['error']}")
795
+
796
+ # Reconstruct Message from dict
797
+ return self.bound.service._dict_to_message(result)
798
+
799
+ def _wrap_streaming_response(self, iterator):
800
+ """Wrap streaming chunks for Anthropic-compatible iteration."""
801
+ for chunk in iterator:
802
+ if isinstance(chunk, dict):
803
+ if 'error' in chunk:
804
+ raise Exception(f"MACAW streaming error: {chunk['error']}")
805
+ yield chunk
806
+ else:
807
+ yield chunk
808
+
809
+ def stream(self, **kwargs):
810
+ """
811
+ Stream message creation - context manager style.
812
+
813
+ Usage:
814
+ with user_client.messages.stream(...) as stream:
815
+ for text in stream.text_stream:
816
+ print(text, end="")
817
+ """
818
+ # Use create with stream=True, wrap in context manager
819
+ kwargs['stream'] = True
820
+ return _StreamContextManager(self.create(**kwargs))
821
+
822
+ class _CompletionsNamespace:
823
+ def __init__(self, bound: 'BoundSecureAnthropic'):
824
+ self.bound = bound
825
+
826
+ def create(self, **kwargs):
827
+ """Create completion via user's client -> service."""
828
+ tool_name = f"tool:{self.bound.service.app_name}/complete"
829
+
830
+ result = self.bound.user_client.invoke_tool(
831
+ tool_name=tool_name,
832
+ parameters=kwargs,
833
+ target_agent=self.bound.service.server_id
834
+ )
835
+
836
+ if isinstance(result, dict) and 'error' in result:
837
+ raise Exception(f"MACAW error: {result['error']}")
838
+
839
+ # Return raw completion dict (legacy API)
840
+ return result