@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.
- package/LICENSE.md +339 -0
- package/bin/install.js +422 -0
- package/package.json +37 -4
- package/payload/README.md +56 -0
- package/payload/secureAI/LICENSE +190 -0
- package/payload/secureAI/NOTICE +5 -0
- package/payload/secureAI/demos/policy-controls/1_uncontrolled.py +139 -0
- package/payload/secureAI/demos/policy-controls/2_with_macaw.py +139 -0
- package/payload/secureAI/demos/policy-controls/3_with_user_policies.py +162 -0
- package/payload/secureAI/demos/policy-controls/credentials.txt +12 -0
- package/payload/secureAI/demos/policy-controls/malicious_report.txt +21 -0
- package/payload/secureAI/demos/policy-controls/sales_report.txt +20 -0
- package/payload/secureAI/demos/tutorial-1-rbac/README.md +143 -0
- package/payload/secureAI/demos/tutorial-1-rbac/app.py +147 -0
- package/payload/secureAI/demos/tutorial-1-rbac/authprompts_demo.py +306 -0
- package/payload/secureAI/demos/tutorial-1-rbac/config/claims-config.yaml +152 -0
- package/payload/secureAI/demos/tutorial-1-rbac/demo_secureopenai_with_policies.py +150 -0
- package/payload/secureAI/demos/tutorial-1-rbac/policies/bu_Analytics.json +8 -0
- package/payload/secureAI/demos/tutorial-1-rbac/policies/company_FinTech_Corp.json +70 -0
- package/payload/secureAI/demos/tutorial-1-rbac/policies/llm-base.json +69 -0
- package/payload/secureAI/demos/tutorial-1-rbac/policies/team_Reporting.json +8 -0
- package/payload/secureAI/demos/tutorial-1-rbac/policies/user_alice.json +32 -0
- package/payload/secureAI/demos/tutorial-1-rbac/policies/user_bob.json +28 -0
- package/payload/secureAI/demos/tutorial-1-rbac/policies/user_carol.json +34 -0
- package/payload/secureAI/demos/tutorial-1-rbac/requirements.txt +6 -0
- package/payload/secureAI/demos/tutorial-1-rbac/setup/auth0_complete_setup.sh +392 -0
- package/payload/secureAI/demos/tutorial-1-rbac/setup/keycloak_complete_setup.sh +320 -0
- package/payload/secureAI/demos/tutorial-2-intent/Policies/alation-remote-proxy.json +155 -0
- package/payload/secureAI/demos/tutorial-2-intent/Policies/bu.json +22 -0
- package/payload/secureAI/demos/tutorial-2-intent/Policies/company.json +17 -0
- package/payload/secureAI/demos/tutorial-2-intent/Policies/user_alice.json +48 -0
- package/payload/secureAI/demos/tutorial-2-intent/Policies/user_bob.json +83 -0
- package/payload/secureAI/demos/tutorial-2-intent/README.md +213 -0
- package/payload/secureAI/demos/tutorial-2-intent/script.py +188 -0
- package/payload/secureAI/demos/tutorial-2-intent/setup/alation_verifier.py +177 -0
- package/payload/secureAI/demos/tutorial-2-intent/setup/get_alation_user_token.py +180 -0
- package/payload/secureAI/examples/README.md +244 -0
- package/payload/secureAI/examples/anthropic/anthropic_1a_dropin_simple.py +103 -0
- package/payload/secureAI/examples/anthropic/anthropic_1b_multiuser_bind.py +190 -0
- package/payload/secureAI/examples/anthropic/anthropic_1b_multiuser_bind_streaming.py +190 -0
- package/payload/secureAI/examples/anthropic/anthropic_1c_a2a_invoke.py +156 -0
- package/payload/secureAI/examples/attestations/1a_trade_alice.py +215 -0
- package/payload/secureAI/examples/attestations/1a_trade_bob.py +164 -0
- package/payload/secureAI/examples/attestations/1b_grant_admin.py +170 -0
- package/payload/secureAI/examples/attestations/1b_grant_researcher.py +231 -0
- package/payload/secureAI/examples/attestations/README.md +128 -0
- package/payload/secureAI/examples/langchain/langchain_1a_dropin_simple.py +199 -0
- package/payload/secureAI/examples/langchain/langchain_1b_multiuser.py +262 -0
- package/payload/secureAI/examples/langchain/langchain_1c_orchestration.py +332 -0
- package/payload/secureAI/examples/langchain/langchain_1d_llm_openai.py +130 -0
- package/payload/secureAI/examples/langchain/langchain_1e_llm_anthropic.py +130 -0
- package/payload/secureAI/examples/langchain/langchain_1f_memory.py +214 -0
- package/payload/secureAI/examples/litellm/litellm_1a_dropin_simple.py +131 -0
- package/payload/secureAI/examples/mcp/1a_simple_invocation.py +115 -0
- package/payload/secureAI/examples/mcp/1b_discovery_and_resources.py +142 -0
- package/payload/secureAI/examples/mcp/1c_logging_client.py +100 -0
- package/payload/secureAI/examples/mcp/1d_progress_client.py +100 -0
- package/payload/secureAI/examples/mcp/1e_sampling_client.py +150 -0
- package/payload/secureAI/examples/mcp/1e_sampling_server.py +90 -0
- package/payload/secureAI/examples/mcp/1f_elicitation_client.py +127 -0
- package/payload/secureAI/examples/mcp/1f_elicitation_server.py +73 -0
- package/payload/secureAI/examples/mcp/1g_roots_client.py +122 -0
- package/payload/secureAI/examples/mcp/1g_roots_server.py +85 -0
- package/payload/secureAI/examples/mcp/2a_proxy_snowflake.py +212 -0
- package/payload/secureAI/examples/mcp/2b_proxy_databricks.py +299 -0
- package/payload/secureAI/examples/mcp/2c_proxy_stdio_fetch.py +91 -0
- package/payload/secureAI/examples/mcp/2d_proxy_stdio_salesforce.py +119 -0
- package/payload/secureAI/examples/mcp/2e_proxy_multiuser.py +136 -0
- package/payload/secureAI/examples/mcp/2f_proxy_stdio_databricks.py +144 -0
- package/payload/secureAI/examples/mcp/3a_native_mcp_client.py +104 -0
- package/payload/secureAI/examples/mcp/3b_native_mcp_proxy.py +114 -0
- package/payload/secureAI/examples/mcp/3c_native_mcp_sampling.py +180 -0
- package/payload/secureAI/examples/mcp/3d_native_mcp_2026.py +135 -0
- package/payload/secureAI/examples/mcp/securemcp_calculator.py +248 -0
- package/payload/secureAI/examples/mcp/securemcp_server.py +75 -0
- package/payload/secureAI/examples/openai/openai_1a_dropin_simple.py +102 -0
- package/payload/secureAI/examples/openai/openai_1b_multiuser_bind.py +193 -0
- package/payload/secureAI/examples/openai/openai_1b_multiuser_bind_streaming.py +199 -0
- package/payload/secureAI/examples/openai/openai_1c_a2a_invoke.py +156 -0
- package/payload/secureAI/examples/openai/openai_1d_invocation_patterns.py +337 -0
- package/payload/secureAI/examples/phishing-resistance/1a_login_alice.py +180 -0
- package/payload/secureAI/examples/phishing-resistance/1b_trade_alice.py +241 -0
- package/payload/secureAI/examples/phishing-resistance/1b_trade_bob.py +164 -0
- package/payload/secureAI/examples/phishing-resistance/README.md +124 -0
- package/payload/secureAI/examples/phishing-resistance/get_token.py +114 -0
- package/payload/secureAI/examples/pydantic_ai/README.md +88 -0
- package/payload/secureAI/examples/pydantic_ai/policies/app_pydantic-agent.json +10 -0
- package/payload/secureAI/examples/pydantic_ai/pydanticai_1a_dropin_simple.py +90 -0
- package/payload/secureAI/examples/pydantic_ai/pydanticai_1b_model_routing.py +85 -0
- package/payload/secureAI/examples/pydantic_ai/pydanticai_1c_native_tools.py +83 -0
- package/payload/secureAI/examples/pydantic_ai/pydanticai_1d_multiuser_bind.py +104 -0
- package/payload/secureAI/examples/pydantic_ai/pydanticai_1e_mcp_compose.py +92 -0
- package/payload/secureAI/macaw_adapters/__init__.py +55 -0
- package/payload/secureAI/macaw_adapters/anthropic/REFERENCE.md +374 -0
- package/payload/secureAI/macaw_adapters/anthropic/__init__.py +22 -0
- package/payload/secureAI/macaw_adapters/anthropic/secure_anthropic.py +792 -0
- package/payload/secureAI/macaw_adapters/anthropic_ext_tools/__init__.py +25 -0
- package/payload/secureAI/macaw_adapters/anthropic_ext_tools/secure_anthropic.py +840 -0
- package/payload/secureAI/macaw_adapters/langchain/REFERENCE.md +234 -0
- package/payload/secureAI/macaw_adapters/langchain/__init__.py +100 -0
- package/payload/secureAI/macaw_adapters/langchain/_utils.py +83 -0
- package/payload/secureAI/macaw_adapters/langchain/agents.py +296 -0
- package/payload/secureAI/macaw_adapters/langchain/anthropic.py +456 -0
- package/payload/secureAI/macaw_adapters/langchain/callbacks.py +306 -0
- package/payload/secureAI/macaw_adapters/langchain/memory.py +352 -0
- package/payload/secureAI/macaw_adapters/langchain/openai.py +410 -0
- package/payload/secureAI/macaw_adapters/langchain/tools.py +174 -0
- package/payload/secureAI/macaw_adapters/litellm/README.md +216 -0
- package/payload/secureAI/macaw_adapters/litellm/__init__.py +143 -0
- package/payload/secureAI/macaw_adapters/litellm/secure_litellm.py +686 -0
- package/payload/secureAI/macaw_adapters/mcp/REFERENCE.md +460 -0
- package/payload/secureAI/macaw_adapters/mcp/__init__.py +65 -0
- package/payload/secureAI/macaw_adapters/mcp/_endpoint.py +193 -0
- package/payload/secureAI/macaw_adapters/mcp/_endpoint2.py +225 -0
- package/payload/secureAI/macaw_adapters/mcp/_serve.py +25 -0
- package/payload/secureAI/macaw_adapters/mcp/client.py +721 -0
- package/payload/secureAI/macaw_adapters/mcp/decorators.py +228 -0
- package/payload/secureAI/macaw_adapters/mcp/fastmcp_compat.py +353 -0
- package/payload/secureAI/macaw_adapters/mcp/mcp.py +785 -0
- package/payload/secureAI/macaw_adapters/mcp/proxy.py +626 -0
- package/payload/secureAI/macaw_adapters/mcp/server.py +317 -0
- package/payload/secureAI/macaw_adapters/mcp/subscriptions.py +450 -0
- package/payload/secureAI/macaw_adapters/openai/REFERENCE.md +368 -0
- package/payload/secureAI/macaw_adapters/openai/__init__.py +22 -0
- package/payload/secureAI/macaw_adapters/openai/secure_openai.py +833 -0
- package/payload/secureAI/macaw_adapters/openai_ext_tools/REFERENCE.md +368 -0
- package/payload/secureAI/macaw_adapters/openai_ext_tools/__init__.py +25 -0
- package/payload/secureAI/macaw_adapters/openai_ext_tools/secure_openai.py +876 -0
- package/payload/secureAI/macaw_adapters/pydantic_ai/__init__.py +36 -0
- package/payload/secureAI/macaw_adapters/pydantic_ai/_core.py +257 -0
- package/payload/secureAI/macaw_adapters/pydantic_ai/agent.py +218 -0
- package/payload/secureAI/macaw_adapters/pydantic_ai/model.py +99 -0
- package/payload/secureAI/macaw_adapters/pydantic_ai/toolset.py +86 -0
- package/payload/secureAI/test_harness_quick.py +296 -0
- package/wheels/macaw_adapters-0.9.10.1-py3-none-any.whl +0 -0
- package/wheels/macaw_client-0.9.10.1-cp312-cp312-macosx_11_0_arm64.whl +0 -0
- package/wheels/macaw_client-0.9.10.1-cp312-cp312-manylinux_2_17_x86_64.whl +0 -0
- package/wheels/macaw_client-0.9.10.1-cp314-cp314-macosx_11_0_arm64.whl +0 -0
- package/wheels/macaw_client-0.9.10.1-cp314-cp314-manylinux_2_17_x86_64.whl +0 -0
- package/wheels/macaw_client-0.9.10.1-cp314-cp314-win_amd64.whl +0 -0
- package/wheels/macaw_client-0.9.10.1-cp39-cp39-macosx_11_0_arm64.whl +0 -0
- package/README.md +0 -3
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SecureMCPProxy - Inline Gateway for External MCP Servers.
|
|
4
|
+
|
|
5
|
+
Wraps any third-party MCP server (Salesforce, Google, Slack, etc.)
|
|
6
|
+
with MACAW security: policy enforcement, cryptographic signing, audit.
|
|
7
|
+
|
|
8
|
+
This is an "inline gateway" - it runs in your process and proxies calls
|
|
9
|
+
to external MCP servers through MACAW's security layer.
|
|
10
|
+
|
|
11
|
+
Supports two transport modes:
|
|
12
|
+
- HTTP: Connect to remote MCP servers via HTTP/SSE
|
|
13
|
+
- stdio: Connect to local MCP servers via subprocess stdin/stdout
|
|
14
|
+
|
|
15
|
+
Usage (HTTP):
|
|
16
|
+
from macaw_adapters.mcp import SecureMCPProxy
|
|
17
|
+
|
|
18
|
+
proxy = SecureMCPProxy(
|
|
19
|
+
app_name="salesforce-mcp",
|
|
20
|
+
upstream_url="https://mcp.salesforce.com",
|
|
21
|
+
upstream_auth={"type": "bearer", "token": SF_TOKEN}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
Usage (stdio):
|
|
25
|
+
proxy = SecureMCPProxy(
|
|
26
|
+
app_name="salesforce-dx",
|
|
27
|
+
command=["npx", "@salesforce/mcp", "--orgs", "DEFAULT"],
|
|
28
|
+
env={"HOME": os.environ["HOME"]}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
Common API (transport-agnostic):
|
|
32
|
+
tools = proxy.list_tools()
|
|
33
|
+
result = proxy.call_tool("query_accounts", {"limit": 10})
|
|
34
|
+
user_proxy = proxy.bind_to_user(user_client)
|
|
35
|
+
|
|
36
|
+
Security Model:
|
|
37
|
+
- MACAW creates AuthenticatedContext for EACH invocation
|
|
38
|
+
- Even with persistent stdio, each call has fresh identity/policy/signature
|
|
39
|
+
- This solves the "shared context" problem in standard MCP
|
|
40
|
+
|
|
41
|
+
Install: pip install macaw-adapters[mcp-proxy]
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import asyncio
|
|
45
|
+
import logging
|
|
46
|
+
from typing import Dict, Any, Optional, List
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tool_schema(tool) -> dict:
|
|
53
|
+
"""Input schema of an upstream tool.
|
|
54
|
+
|
|
55
|
+
mcp>=2 renamed Tool.inputSchema to Tool.input_schema. Reading the wrong one
|
|
56
|
+
yields no error, just an empty schema - and an empty schema means the proxy
|
|
57
|
+
registers the tool with no parameter constraints for policy to bind to.
|
|
58
|
+
"""
|
|
59
|
+
return (
|
|
60
|
+
getattr(tool, "input_schema", None)
|
|
61
|
+
or getattr(tool, "inputSchema", None)
|
|
62
|
+
or {}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class UpstreamAuth:
|
|
68
|
+
"""Authentication configuration for upstream MCP server."""
|
|
69
|
+
type: str = "none" # "none", "bearer", "api_key", "oauth"
|
|
70
|
+
token: Optional[str] = None
|
|
71
|
+
api_key: Optional[str] = None
|
|
72
|
+
header_name: str = "Authorization" # For custom header names
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SecureMCPProxy:
|
|
76
|
+
"""
|
|
77
|
+
Inline gateway that wraps external MCP servers with MACAW security.
|
|
78
|
+
|
|
79
|
+
Connects to any MCP server (Salesforce, Google, Slack, etc.) and:
|
|
80
|
+
- Auto-discovers available tools via MCP protocol
|
|
81
|
+
- Re-exposes tools through MACAWClient (policy, signing, audit)
|
|
82
|
+
- Supports multi-user identity via bind_to_user()
|
|
83
|
+
|
|
84
|
+
Transports:
|
|
85
|
+
- HTTP: For remote MCP servers (Snowflake, Databricks, Google Cloud)
|
|
86
|
+
- stdio: For local MCP servers run as subprocesses (Salesforce DX CLI)
|
|
87
|
+
|
|
88
|
+
Security Model:
|
|
89
|
+
- MACAW creates AuthenticatedContext for EACH invocation
|
|
90
|
+
- Even with persistent stdio subprocess, each call gets fresh context
|
|
91
|
+
- Policy enforcement, signing, and audit happen per-invocation
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
app_name: str,
|
|
97
|
+
# HTTP transport options
|
|
98
|
+
upstream_url: Optional[str] = None,
|
|
99
|
+
upstream_auth: Optional[Dict[str, Any]] = None,
|
|
100
|
+
# stdio transport options
|
|
101
|
+
command: Optional[List[str]] = None,
|
|
102
|
+
env: Optional[Dict[str, str]] = None,
|
|
103
|
+
# Identity (for single-user or service mode)
|
|
104
|
+
iam_token: Optional[str] = None,
|
|
105
|
+
user_name: Optional[str] = None,
|
|
106
|
+
# Optional intent policy
|
|
107
|
+
intent_policy: Optional[Dict[str, Any]] = None,
|
|
108
|
+
):
|
|
109
|
+
"""
|
|
110
|
+
Initialize SecureMCPProxy.
|
|
111
|
+
|
|
112
|
+
Provide either upstream_url (HTTP) or command (stdio), not both.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
app_name: Application name for MACAW registration (e.g., "salesforce-mcp")
|
|
116
|
+
|
|
117
|
+
HTTP transport:
|
|
118
|
+
upstream_url: URL of the upstream MCP server
|
|
119
|
+
upstream_auth: Authentication config:
|
|
120
|
+
{"type": "bearer", "token": "..."} or
|
|
121
|
+
{"type": "api_key", "api_key": "...", "header_name": "X-API-Key"}
|
|
122
|
+
|
|
123
|
+
stdio transport:
|
|
124
|
+
command: Command to start MCP server (e.g., ["npx", "@salesforce/mcp"])
|
|
125
|
+
env: Environment variables for subprocess
|
|
126
|
+
|
|
127
|
+
Common:
|
|
128
|
+
iam_token: Optional IAM token for user identity
|
|
129
|
+
user_name: Optional user name
|
|
130
|
+
intent_policy: Optional MACAW intent policy
|
|
131
|
+
"""
|
|
132
|
+
self.app_name = app_name
|
|
133
|
+
self.intent_policy = intent_policy or {}
|
|
134
|
+
|
|
135
|
+
# Determine transport
|
|
136
|
+
if command and upstream_url:
|
|
137
|
+
raise ValueError("Provide either upstream_url (HTTP) or command (stdio), not both")
|
|
138
|
+
elif command:
|
|
139
|
+
self._transport = "stdio"
|
|
140
|
+
self._stdio_command = command
|
|
141
|
+
self._stdio_env = env or {}
|
|
142
|
+
self.upstream_url = f"stdio://{command[0]}" # For display
|
|
143
|
+
logger.info(f"Using stdio transport: {' '.join(command)}")
|
|
144
|
+
elif upstream_url:
|
|
145
|
+
self._transport = "http"
|
|
146
|
+
self.upstream_url = upstream_url
|
|
147
|
+
self.upstream_auth = UpstreamAuth(**(upstream_auth or {}))
|
|
148
|
+
logger.info(f"Using HTTP transport: {upstream_url}")
|
|
149
|
+
else:
|
|
150
|
+
raise ValueError("Must provide either upstream_url (HTTP) or command (stdio)")
|
|
151
|
+
|
|
152
|
+
# State
|
|
153
|
+
self.tool_schemas: Dict[str, Dict[str, Any]] = {}
|
|
154
|
+
self._connected = False
|
|
155
|
+
|
|
156
|
+
# Connect to upstream and discover tools
|
|
157
|
+
self._connect_and_discover()
|
|
158
|
+
|
|
159
|
+
# Setup MACAWClient with discovered tools
|
|
160
|
+
self._setup_macaw_client(iam_token, user_name)
|
|
161
|
+
|
|
162
|
+
def _connect_and_discover(self):
|
|
163
|
+
"""Connect to upstream MCP server and discover tools."""
|
|
164
|
+
try:
|
|
165
|
+
if self._transport == "stdio":
|
|
166
|
+
asyncio.run(self._async_discover_tools_stdio())
|
|
167
|
+
else:
|
|
168
|
+
asyncio.run(self._async_discover_tools_http())
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.error(f"Failed to connect to upstream: {e}")
|
|
171
|
+
raise ConnectionError(f"Cannot connect to upstream MCP server: {e}")
|
|
172
|
+
|
|
173
|
+
async def _async_discover_tools_http(self):
|
|
174
|
+
"""Discover tools from upstream MCP server via HTTP."""
|
|
175
|
+
try:
|
|
176
|
+
from mcp import ClientSession
|
|
177
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
178
|
+
except ImportError:
|
|
179
|
+
raise ImportError(
|
|
180
|
+
"MCP client not installed. Install with: pip install macaw-adapters[mcp-proxy]"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Build HTTP client with auth
|
|
184
|
+
http_client = self._create_http_client()
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
# mcp<2 yields (read, write, get_session_id); mcp>=2 yields (read, write)
|
|
188
|
+
# because protocol sessions were removed in 2026-07-28. Index instead of
|
|
189
|
+
# unpacking so one call site works on both - the session id getter was
|
|
190
|
+
# already discarded.
|
|
191
|
+
async with streamable_http_client(
|
|
192
|
+
self.upstream_url,
|
|
193
|
+
http_client=http_client
|
|
194
|
+
) as streams:
|
|
195
|
+
read_stream, write_stream = streams[0], streams[1]
|
|
196
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
197
|
+
await session.initialize()
|
|
198
|
+
|
|
199
|
+
# Discover tools
|
|
200
|
+
response = await session.list_tools()
|
|
201
|
+
|
|
202
|
+
for tool in response.tools:
|
|
203
|
+
self.tool_schemas[tool.name] = {
|
|
204
|
+
"name": tool.name,
|
|
205
|
+
"description": tool.description or f"Tool: {tool.name}",
|
|
206
|
+
"schema": _tool_schema(tool)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
self._connected = True
|
|
210
|
+
logger.info(f"Discovered {len(self.tool_schemas)} tools from {self.upstream_url}")
|
|
211
|
+
|
|
212
|
+
finally:
|
|
213
|
+
if http_client:
|
|
214
|
+
await http_client.aclose()
|
|
215
|
+
|
|
216
|
+
async def _async_discover_tools_stdio(self):
|
|
217
|
+
"""Discover tools from upstream MCP server via stdio subprocess."""
|
|
218
|
+
try:
|
|
219
|
+
from mcp import ClientSession
|
|
220
|
+
from mcp.client.stdio import stdio_client, StdioServerParameters
|
|
221
|
+
except ImportError:
|
|
222
|
+
raise ImportError(
|
|
223
|
+
"MCP client not installed. Install with: pip install macaw-adapters[mcp-proxy]"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
params = StdioServerParameters(
|
|
227
|
+
command=self._stdio_command[0],
|
|
228
|
+
args=self._stdio_command[1:] if len(self._stdio_command) > 1 else [],
|
|
229
|
+
env=self._stdio_env or None
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
async with stdio_client(params) as (read_stream, write_stream):
|
|
233
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
234
|
+
await session.initialize()
|
|
235
|
+
|
|
236
|
+
# Discover tools
|
|
237
|
+
response = await session.list_tools()
|
|
238
|
+
|
|
239
|
+
for tool in response.tools:
|
|
240
|
+
self.tool_schemas[tool.name] = {
|
|
241
|
+
"name": tool.name,
|
|
242
|
+
"description": tool.description or f"Tool: {tool.name}",
|
|
243
|
+
"schema": _tool_schema(tool)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
self._connected = True
|
|
247
|
+
logger.info(f"Discovered {len(self.tool_schemas)} tools via stdio")
|
|
248
|
+
|
|
249
|
+
def _create_http_client(self):
|
|
250
|
+
"""Create httpx client with authentication headers.
|
|
251
|
+
|
|
252
|
+
mcp>=2 is built on httpx2 and type-checks the client it is handed, so the
|
|
253
|
+
client has to come from the same library the installed SDK uses.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
try:
|
|
257
|
+
import httpx2 as httpx # mcp>=2
|
|
258
|
+
except ImportError:
|
|
259
|
+
import httpx # mcp<2
|
|
260
|
+
except ImportError:
|
|
261
|
+
raise ImportError(
|
|
262
|
+
"httpx not installed. Install with: pip install macaw-adapters[mcp-proxy]"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
headers = {}
|
|
266
|
+
|
|
267
|
+
if self.upstream_auth.type == "bearer" and self.upstream_auth.token:
|
|
268
|
+
headers["Authorization"] = f"Bearer {self.upstream_auth.token}"
|
|
269
|
+
elif self.upstream_auth.type == "api_key" and self.upstream_auth.api_key:
|
|
270
|
+
header_name = self.upstream_auth.header_name or "X-API-Key"
|
|
271
|
+
headers[header_name] = self.upstream_auth.api_key
|
|
272
|
+
|
|
273
|
+
# Always return a client, even without auth headers: httpx defaults to a
|
|
274
|
+
# 5s read timeout, which any upstream tool that calls an LLM will exceed.
|
|
275
|
+
# Letting the MCP SDK build its own client would reinstate that default.
|
|
276
|
+
return httpx.AsyncClient(
|
|
277
|
+
headers=headers or None,
|
|
278
|
+
timeout=httpx.Timeout(connect=30, read=300, write=30, pool=30),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _setup_macaw_client(self, iam_token: Optional[str], user_name: Optional[str]):
|
|
282
|
+
"""
|
|
283
|
+
Create MACAWClient with discovered tools as proxy handlers.
|
|
284
|
+
|
|
285
|
+
Key security property: MACAWClient creates AuthenticatedContext for
|
|
286
|
+
EACH invocation, providing per-call identity, policy, and signing.
|
|
287
|
+
This solves the "shared context" problem even with persistent stdio.
|
|
288
|
+
"""
|
|
289
|
+
try:
|
|
290
|
+
from macaw_client import MACAWClient
|
|
291
|
+
except ImportError:
|
|
292
|
+
raise ImportError(
|
|
293
|
+
"MACAWClient not installed. Download from https://console.macawsecurity.ai"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Build tool config with proxy handlers
|
|
297
|
+
# Use MAPL-compliant names: tool:{app_name}/{tool_name}
|
|
298
|
+
tools_config = {}
|
|
299
|
+
tool_names = []
|
|
300
|
+
|
|
301
|
+
for name, schema in self.tool_schemas.items():
|
|
302
|
+
mapl_name = f"tool:{self.app_name}/{name}"
|
|
303
|
+
tool_names.append(mapl_name)
|
|
304
|
+
tools_config[mapl_name] = {
|
|
305
|
+
"handler": self._make_proxy_handler(name),
|
|
306
|
+
"description": schema.get("description", ""),
|
|
307
|
+
"metadata": {
|
|
308
|
+
"schema": schema.get("schema", {}),
|
|
309
|
+
"upstream": self.upstream_url,
|
|
310
|
+
"transport": self._transport,
|
|
311
|
+
"proxy": True
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# Build intent policy
|
|
316
|
+
full_intent_policy = {
|
|
317
|
+
"provided_capabilities": [{
|
|
318
|
+
"type": "tools",
|
|
319
|
+
"tools": tool_names,
|
|
320
|
+
"description": f"Proxied tools from {self.upstream_url} via {self._transport}"
|
|
321
|
+
}],
|
|
322
|
+
"description": f"SecureMCPProxy: {self.app_name} [{self._transport}] -> {self.upstream_url}",
|
|
323
|
+
**self.intent_policy
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# Create MACAWClient
|
|
327
|
+
self.macaw_client = MACAWClient(
|
|
328
|
+
app_name=self.app_name,
|
|
329
|
+
app_version="1.0.0",
|
|
330
|
+
iam_token=iam_token,
|
|
331
|
+
user_name=user_name,
|
|
332
|
+
intent_policy=full_intent_policy,
|
|
333
|
+
tools=tools_config
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Register with LocalAgent
|
|
337
|
+
if not self.macaw_client.register():
|
|
338
|
+
raise RuntimeError("Failed to register with MACAW LocalAgent")
|
|
339
|
+
|
|
340
|
+
self.agent_id = self.macaw_client.agent_id
|
|
341
|
+
self.server_id = self.agent_id # Alias for consistency with other adapters
|
|
342
|
+
|
|
343
|
+
logger.info(f"SecureMCPProxy registered: {self.agent_id}")
|
|
344
|
+
logger.info(f" Tools: {list(self.tool_schemas.keys())}")
|
|
345
|
+
|
|
346
|
+
def _make_proxy_handler(self, tool_name: str):
|
|
347
|
+
"""Create a handler that proxies calls to upstream MCP server."""
|
|
348
|
+
def handler(params: Dict[str, Any]) -> Any:
|
|
349
|
+
# This executes AFTER MACAWClient enforces policy
|
|
350
|
+
# MACAW has already created AuthenticatedContext for this call
|
|
351
|
+
if self._transport == "stdio":
|
|
352
|
+
result = asyncio.run(self._call_upstream_stdio(tool_name, params))
|
|
353
|
+
else:
|
|
354
|
+
result = asyncio.run(self._call_upstream_http(tool_name, params))
|
|
355
|
+
|
|
356
|
+
# Wrap result in dict format expected by MACAWClient
|
|
357
|
+
if not isinstance(result, dict):
|
|
358
|
+
result = {"result": result}
|
|
359
|
+
return result
|
|
360
|
+
return handler
|
|
361
|
+
|
|
362
|
+
async def _call_upstream_http(self, tool_name: str, params: Dict[str, Any]) -> Any:
|
|
363
|
+
"""Call a tool on the upstream MCP server via HTTP."""
|
|
364
|
+
try:
|
|
365
|
+
from mcp import ClientSession
|
|
366
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
367
|
+
except ImportError:
|
|
368
|
+
raise ImportError("MCP client not available")
|
|
369
|
+
|
|
370
|
+
http_client = self._create_http_client()
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
# See _async_discover_tools_http() for why this is indexed, not unpacked.
|
|
374
|
+
async with streamable_http_client(
|
|
375
|
+
self.upstream_url,
|
|
376
|
+
http_client=http_client
|
|
377
|
+
) as streams:
|
|
378
|
+
read_stream, write_stream = streams[0], streams[1]
|
|
379
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
380
|
+
await session.initialize()
|
|
381
|
+
result = await session.call_tool(tool_name, params)
|
|
382
|
+
return self._extract_result(result)
|
|
383
|
+
|
|
384
|
+
finally:
|
|
385
|
+
if http_client:
|
|
386
|
+
await http_client.aclose()
|
|
387
|
+
|
|
388
|
+
async def _call_upstream_stdio(self, tool_name: str, params: Dict[str, Any]) -> Any:
|
|
389
|
+
"""Call a tool on the upstream MCP server via stdio subprocess."""
|
|
390
|
+
try:
|
|
391
|
+
from mcp import ClientSession
|
|
392
|
+
from mcp.client.stdio import stdio_client, StdioServerParameters
|
|
393
|
+
except ImportError:
|
|
394
|
+
raise ImportError("MCP client not available")
|
|
395
|
+
|
|
396
|
+
params_obj = StdioServerParameters(
|
|
397
|
+
command=self._stdio_command[0],
|
|
398
|
+
args=self._stdio_command[1:] if len(self._stdio_command) > 1 else [],
|
|
399
|
+
env=self._stdio_env or None
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
async with stdio_client(params_obj) as (read_stream, write_stream):
|
|
403
|
+
async with ClientSession(read_stream, write_stream) as session:
|
|
404
|
+
await session.initialize()
|
|
405
|
+
result = await session.call_tool(tool_name, params)
|
|
406
|
+
return self._extract_result(result)
|
|
407
|
+
|
|
408
|
+
def _extract_result(self, result: Any) -> Any:
|
|
409
|
+
"""Extract content from MCP result format."""
|
|
410
|
+
if hasattr(result, 'content'):
|
|
411
|
+
# Handle MCP result format
|
|
412
|
+
if isinstance(result.content, list) and len(result.content) > 0:
|
|
413
|
+
content = result.content[0]
|
|
414
|
+
if hasattr(content, 'text'):
|
|
415
|
+
return content.text
|
|
416
|
+
return content
|
|
417
|
+
return result.content
|
|
418
|
+
return result
|
|
419
|
+
|
|
420
|
+
# =========================================================================
|
|
421
|
+
# PUBLIC API
|
|
422
|
+
# =========================================================================
|
|
423
|
+
|
|
424
|
+
def call_tool(self, tool_name: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
425
|
+
"""
|
|
426
|
+
Call a tool through MACAW security.
|
|
427
|
+
|
|
428
|
+
Flow:
|
|
429
|
+
1. MACAWClient.invoke_tool() checks MAPL policy
|
|
430
|
+
2. Signs the invocation
|
|
431
|
+
3. Logs to audit
|
|
432
|
+
4. Proxy handler forwards to upstream MCP server
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
tool_name: Name of the tool (e.g., "query_accounts")
|
|
436
|
+
params: Tool parameters
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Tool execution result from upstream
|
|
440
|
+
|
|
441
|
+
Raises:
|
|
442
|
+
PermissionError: If policy denies access
|
|
443
|
+
ConnectionError: If upstream is unreachable
|
|
444
|
+
"""
|
|
445
|
+
if tool_name not in self.tool_schemas:
|
|
446
|
+
available = list(self.tool_schemas.keys())
|
|
447
|
+
raise ValueError(f"Unknown tool: {tool_name}. Available: {available}")
|
|
448
|
+
|
|
449
|
+
# Use MAPL-compliant name: tool:{app_name}/{tool_name}
|
|
450
|
+
mapl_name = f"tool:{self.app_name}/{tool_name}"
|
|
451
|
+
|
|
452
|
+
# Invoke through MACAW (goes through PEP)
|
|
453
|
+
return self.macaw_client.invoke_tool(
|
|
454
|
+
tool_name=mapl_name,
|
|
455
|
+
parameters=params or {},
|
|
456
|
+
target_agent=self.agent_id
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def list_tools(self) -> List[Dict[str, Any]]:
|
|
460
|
+
"""
|
|
461
|
+
List available tools from upstream MCP server.
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
List of tool schemas with name, description, and input schema
|
|
465
|
+
"""
|
|
466
|
+
return list(self.tool_schemas.values())
|
|
467
|
+
|
|
468
|
+
def get_tool_schema(self, tool_name: str) -> Optional[Dict[str, Any]]:
|
|
469
|
+
"""
|
|
470
|
+
Get schema for a specific tool.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
tool_name: Name of the tool
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
Tool schema or None if not found
|
|
477
|
+
"""
|
|
478
|
+
return self.tool_schemas.get(tool_name)
|
|
479
|
+
|
|
480
|
+
def bind_to_user(self, user_client) -> "BoundMCPProxy":
|
|
481
|
+
"""
|
|
482
|
+
Bind proxy to a specific user's identity.
|
|
483
|
+
|
|
484
|
+
For multi-tenant SaaS apps where the service maintains the upstream
|
|
485
|
+
connection but each request runs with user's identity and policy.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
user_client: MACAWClient instance with user's identity
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
BoundMCPProxy that routes calls through user's identity
|
|
492
|
+
|
|
493
|
+
Example:
|
|
494
|
+
# Service creates proxy once
|
|
495
|
+
proxy = SecureMCPProxy(app_name="salesforce", ...)
|
|
496
|
+
|
|
497
|
+
# Per-request: bind to user
|
|
498
|
+
user_proxy = proxy.bind_to_user(user_macaw_client)
|
|
499
|
+
result = user_proxy.call_tool("query", {...}) # Alice's policy applies
|
|
500
|
+
"""
|
|
501
|
+
return BoundMCPProxy(self, user_client)
|
|
502
|
+
|
|
503
|
+
def run(self, transport: str = "stdio", port: int = 8080):
|
|
504
|
+
"""
|
|
505
|
+
Serve the proxied upstream tools to native (non-MACAW) MCP clients.
|
|
506
|
+
|
|
507
|
+
Turns this proxy into a drop-in replacement for the upstream server: point
|
|
508
|
+
Claude Desktop / Cursor at this process instead of the real one and every
|
|
509
|
+
tool call is policy-checked, signed, and audited on the way through.
|
|
510
|
+
|
|
511
|
+
proxy = SecureMCPProxy(app_name="databricks", command=["uvx", "..."])
|
|
512
|
+
proxy.run() # stdio
|
|
513
|
+
proxy.run(transport="http", port=8080) # http at /mcp
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
transport: "stdio" or "http" (how external clients reach this proxy;
|
|
517
|
+
independent of the transport used to reach upstream)
|
|
518
|
+
port: Bind port for transport="http"
|
|
519
|
+
"""
|
|
520
|
+
from ._serve import serve
|
|
521
|
+
|
|
522
|
+
asyncio.run(
|
|
523
|
+
serve(
|
|
524
|
+
self.app_name,
|
|
525
|
+
"1.0.0",
|
|
526
|
+
registry=self.macaw_client,
|
|
527
|
+
target_agent=self.agent_id,
|
|
528
|
+
prefix=f"tool:{self.app_name}/",
|
|
529
|
+
transport=transport,
|
|
530
|
+
port=port,
|
|
531
|
+
)
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def refresh_tools(self):
|
|
535
|
+
"""
|
|
536
|
+
Re-discover tools from upstream MCP server.
|
|
537
|
+
|
|
538
|
+
Call this if upstream server's tools may have changed.
|
|
539
|
+
"""
|
|
540
|
+
old_tools = set(self.tool_schemas.keys())
|
|
541
|
+
self._connect_and_discover()
|
|
542
|
+
new_tools = set(self.tool_schemas.keys())
|
|
543
|
+
|
|
544
|
+
added = new_tools - old_tools
|
|
545
|
+
removed = old_tools - new_tools
|
|
546
|
+
|
|
547
|
+
if added:
|
|
548
|
+
logger.info(f"New tools discovered: {added}")
|
|
549
|
+
# Register new tools with MACAWClient
|
|
550
|
+
for name in added:
|
|
551
|
+
schema = self.tool_schemas[name]
|
|
552
|
+
self.macaw_client.register_tool(
|
|
553
|
+
name,
|
|
554
|
+
handler=self._make_proxy_handler(name),
|
|
555
|
+
metadata={"schema": schema.get("schema", {})}
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if removed:
|
|
559
|
+
logger.info(f"Tools no longer available: {removed}")
|
|
560
|
+
|
|
561
|
+
@property
|
|
562
|
+
def is_connected(self) -> bool:
|
|
563
|
+
"""Check if connected to upstream MCP server."""
|
|
564
|
+
return self._connected
|
|
565
|
+
|
|
566
|
+
def __repr__(self) -> str:
|
|
567
|
+
tool_count = len(self.tool_schemas)
|
|
568
|
+
transport = self._transport.upper()
|
|
569
|
+
return f"<SecureMCPProxy {self.app_name} [{transport}] -> {self.upstream_url} ({tool_count} tools)>"
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class BoundMCPProxy:
|
|
573
|
+
"""
|
|
574
|
+
MCP Proxy bound to a specific user's identity.
|
|
575
|
+
|
|
576
|
+
All tool calls route through the user's MACAWClient,
|
|
577
|
+
applying their policies and logging under their identity.
|
|
578
|
+
"""
|
|
579
|
+
|
|
580
|
+
def __init__(self, proxy: SecureMCPProxy, user_client):
|
|
581
|
+
"""
|
|
582
|
+
Initialize bound proxy.
|
|
583
|
+
|
|
584
|
+
Args:
|
|
585
|
+
proxy: The SecureMCPProxy instance
|
|
586
|
+
user_client: MACAWClient with user's identity
|
|
587
|
+
"""
|
|
588
|
+
self.proxy = proxy
|
|
589
|
+
self.user_client = user_client
|
|
590
|
+
|
|
591
|
+
def call_tool(self, tool_name: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
592
|
+
"""
|
|
593
|
+
Call a tool with user's identity and policy context.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
tool_name: Name of the tool
|
|
597
|
+
params: Tool parameters
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
Tool execution result
|
|
601
|
+
"""
|
|
602
|
+
if tool_name not in self.proxy.tool_schemas:
|
|
603
|
+
available = list(self.proxy.tool_schemas.keys())
|
|
604
|
+
raise ValueError(f"Unknown tool: {tool_name}. Available: {available}")
|
|
605
|
+
|
|
606
|
+
# Use MAPL-compliant name: tool:{app_name}/{tool_name}
|
|
607
|
+
mapl_name = f"tool:{self.proxy.app_name}/{tool_name}"
|
|
608
|
+
|
|
609
|
+
# Invoke through user's MACAWClient (their policy applies)
|
|
610
|
+
return self.user_client.invoke_tool(
|
|
611
|
+
tool_name=mapl_name,
|
|
612
|
+
parameters=params or {},
|
|
613
|
+
target_agent=self.proxy.agent_id
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
def list_tools(self) -> List[Dict[str, Any]]:
|
|
617
|
+
"""List available tools."""
|
|
618
|
+
return self.proxy.list_tools()
|
|
619
|
+
|
|
620
|
+
def get_tool_schema(self, tool_name: str) -> Optional[Dict[str, Any]]:
|
|
621
|
+
"""Get schema for a specific tool."""
|
|
622
|
+
return self.proxy.get_tool_schema(tool_name)
|
|
623
|
+
|
|
624
|
+
def __repr__(self) -> str:
|
|
625
|
+
user_id = getattr(self.user_client, 'agent_id', 'unknown')
|
|
626
|
+
return f"<BoundMCPProxy {self.proxy.app_name} as {user_id}>"
|