diff --git a/README.md b/README.md index 900898c..dccc8ce 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,217 @@ npm start npm run dev ``` +## MCP Client with Passport (US-1.2.2) + +This example includes **client-side** examples showing how to attach agent passports to MCP tool calls. This is the agent that makes tool calls to MCP servers. + +### Key Features + +- ✅ **Pre-action policy verification**: Verifies policy BEFORE calling MCP tools using `verifyPolicy()` +- ✅ **Automatic passport attachment**: Agent ID is automatically added to tool call arguments +- ✅ **Policy denial handling**: Graceful retry with adjusted parameters or escalation +- ✅ **Error handling**: Comprehensive error handling with audit trails +- ✅ **Framework integration**: Examples for OpenAI, Anthropic, and custom MCP clients +- ✅ **Published SDK**: Uses `@aporthq/sdk-node` (npm) and `aporthq-sdk-python` (PyPI) + +### Quick Start (TypeScript) + +**Install dependencies:** +```bash +npm install @aporthq/sdk-node @modelcontextprotocol/sdk +``` + +**Usage:** +```typescript +import { MCPClientWithPassport } from './src/client-example'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { APortClient } from '@aporthq/sdk-node'; + +// Initialize APort client (uses published SDK) +const aportClient = new APortClient({ + baseUrl: 'https://api.aport.io', +}); + +const transport = new StdioClientTransport({ + command: 'npx', + args: ['@aporthq/mcp-policy-gate-example'], +}); + +const client = new MCPClientWithPassport('ap_your_agent_id', transport); +await client.connect(transport); + +// Call tool - policy is verified FIRST, then tool executes +const result = await client.callTool( + 'merge_pull_request', + { + repository: 'my-org/my-repo', + pr_number: 123, + base_branch: 'main', + }, + { + retryOnDenial: false, + maxRetries: 3, + } +); + +console.log(result); +// Policy verification happens automatically before tool execution +``` + +### Quick Start (Python) + +**Install dependencies:** +```bash +pip install aporthq-sdk-python +``` + +**Usage:** +```python +from client_example import MCPClientWithPassport +from aporthq_sdk_python import APortClient, APortClientOptions + +# Initialize APort client (uses published SDK) +aport_client = APortClient(APortClientOptions( + base_url='https://api.aport.io', +)) + +async with MCPClientWithPassport('ap_your_agent_id') as client: + # Call tool - policy is verified FIRST, then tool executes + result = await client.call_tool( + 'merge_pull_request', + { + 'repository': 'my-org/my-repo', + 'pr_number': 123, + 'base_branch': 'main', + }, + retry_on_denial=False, + max_retries=3, + ) + + print(result) + # Policy verification happens automatically before tool execution +``` + +### Policy Verification Flow + +The client verifies policy BEFORE each tool call: + +```typescript +// Each call verifies policy first, then executes tool +await client.callTool('merge_pull_request', { + repository: 'my-org/my-repo', + pr_number: 123, +}); +// Flow: 1. Verify policy (code.repository.merge.v1) +// 2. If allowed, call MCP tool with agent_id +// 3. Return result with decision_id +``` + +### Policy Denial Handling + +The client can automatically retry with adjusted parameters: + +```typescript +// Automatic retry with reduced amount +await client.callTool( + 'process_refund', + { amount: 1000000 }, // $10,000 + { + retryOnDenial: true, // Retry if denied + maxRetries: 3, + } +); +// If denied, automatically retries with amount: 500000, then 250000 +``` + +### Integration Examples + +#### OpenAI Function Calling + +See [`openai-integration-example.py`](./openai-integration-example.py) for a complete example showing how to integrate MCP client with OpenAI's function calling API. + +```python +from openai_integration_example import OpenAIWithMCPPassport + +wrapper = OpenAIWithMCPPassport('ap_your_agent_id') + +# OpenAI function calls are automatically routed to MCP tools with passport +response = await wrapper.chat_completion_with_tools( + messages=[{"role": "user", "content": "Refund $50 to customer_123"}], + functions=[...], +) +``` + +#### Anthropic Tool Use + +See [`anthropic-integration-example.py`](./anthropic-integration-example.py) for a complete example showing how to integrate MCP client with Anthropic's tool use API. + +```python +from anthropic_integration_example import AnthropicWithMCPPassport + +wrapper = AnthropicWithMCPPassport('ap_your_agent_id') + +# Anthropic tool use is automatically routed to MCP tools with passport +response = await wrapper.messages_with_tools( + messages=[{"role": "user", "content": "Merge PR #123"}], + tools=[...], +) +``` + +### Running Client Examples + +#### TypeScript + +```bash +# Run client examples +npm run build +node dist/client-example.js + +# Or with tsx +npx tsx src/client-example.ts +``` + +#### Python + +```bash +# Install dependencies +pip install aporthq-sdk-python mcp + +# Run client examples +python client_example.py + +# Run OpenAI integration example +python openai-integration-example.py + +# Run Anthropic integration example +python anthropic-integration-example.py +``` + +### Best Practices + +1. **Always attach agent_id**: The client automatically attaches `agent_id` to all tool calls +2. **Handle policy denials**: Use `retryOnDenial` for operations that can be retried with adjusted parameters +3. **Cache passports**: The client caches passports to reduce API calls +4. **Log decisions**: Always log decision IDs for audit trails +5. **Error handling**: Implement graceful degradation for network errors + +### Error Handling + +```typescript +try { + const result = await client.callTool('process_refund', {...}); +} catch (error) { + if (error instanceof PolicyDeniedError) { + // Policy denied - escalate to human or retry with lower amount + console.error('Policy denied:', error.message); + console.error('Decision ID:', error.result.decision_id); + } else { + // Network or other error + console.error('Error:', error); + } +} +``` + ## Integration with Other MCP Clients ### VS Code (Cline Extension) @@ -207,6 +418,20 @@ const result = await client.request({ console.log(result); ``` +## File Structure + +``` +mcp-policy-gate-example/ +├── src/ +│ ├── index.ts # MCP server (policy enforcement) +│ └── client-example.ts # MCP client (passport attachment) +├── client_example.py # Python MCP client +├── openai-integration-example.py # OpenAI integration +├── anthropic-integration-example.py # Anthropic integration +├── README.md # This file +└── package.json +``` + ## License MIT \ No newline at end of file diff --git a/anthropic-integration-example.py b/anthropic-integration-example.py new file mode 100644 index 0000000..cbcd9bd --- /dev/null +++ b/anthropic-integration-example.py @@ -0,0 +1,256 @@ +""" +Anthropic Tool Use with MCP and APort Passport + +This example shows how to integrate MCP client with Anthropic's tool use API, +automatically attaching agent passports for authorization. + +Prerequisites: + pip install anthropic + pip install aporthq-sdk-python + pip install mcp # Optional, for direct MCP integration +""" + +import os +import asyncio +from typing import Dict, Any, List, Optional + +try: + import anthropic + ANTHROPIC_AVAILABLE = True +except ImportError: + ANTHROPIC_AVAILABLE = False + print("⚠️ Anthropic SDK not installed. Install with: pip install anthropic") + +from aporthq_sdk_python import APortClient, APortClientOptions +from client_example import MCPClientWithPassport, PolicyDeniedError + + +# Configuration +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") +AGENT_ID = os.getenv("APORT_AGENT_ID", "ap_a2d10232c6534523812423eec8a1425c") +APORT_BASE_URL = os.getenv("APORT_BASE_URL", "https://api.aport.io") + + +class AnthropicWithMCPPassport: + """ + Anthropic client wrapper that integrates MCP tools with passport support + """ + + def __init__(self, agent_id: str, anthropic_client: Optional[anthropic.Anthropic] = None): + self.agent_id = agent_id + self.anthropic_client = anthropic_client or ( + anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) if ANTHROPIC_AVAILABLE else None + ) + self.mcp_client: Optional[MCPClientWithPassport] = None + self.aport_client = APortClient(APortClientOptions(base_url=APORT_BASE_URL)) + + async def initialize_mcp(self): + """Initialize MCP client connection""" + self.mcp_client = MCPClientWithPassport(self.agent_id) + await self.mcp_client.connect() + + async def close(self): + """Close connections""" + if self.mcp_client: + await self.mcp_client.close() + await self.aport_client.close() + + def _map_anthropic_tool_to_mcp_tool(self, tool_name: str) -> str: + """ + Map Anthropic tool name to MCP tool name + + In a real implementation, you would maintain a mapping of + Anthropic tool names to MCP tool names. + """ + mapping = { + "merge_pull_request": "merge_pull_request", + "process_refund": "process_refund", + "export_customer_data": "export_customer_data", + } + return mapping.get(tool_name, tool_name) + + async def handle_tool_use( + self, + tool_use: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Handle Anthropic tool use by routing to MCP tool with passport + + This is called when Anthropic requests a tool execution. + """ + if not self.mcp_client: + await self.initialize_mcp() + + tool_name = tool_use.get("name") + tool_input = tool_use.get("input", {}) + + # Map Anthropic tool to MCP tool + mcp_tool_name = self._map_anthropic_tool_to_mcp_tool(tool_name) + + try: + # Call MCP tool with passport attached + result = await self.mcp_client.call_tool( + mcp_tool_name, + tool_input, + retry_on_denial=False, # Anthropic handles retries differently + max_retries=1, + ) + + # Format result for Anthropic + if result.content: + text_content = next( + (c.get("text", "") for c in result.content if c.get("type") == "text"), + "" + ) + return { + "tool_use_id": tool_use.get("id"), + "content": text_content, + } + + return { + "tool_use_id": tool_use.get("id"), + "content": "Tool executed successfully", + } + + except PolicyDeniedError as error: + # Return policy denial to Anthropic + return { + "tool_use_id": tool_use.get("id"), + "content": f"Policy denied: {error}", + } + except Exception as error: + return { + "tool_use_id": tool_use.get("id"), + "content": f"Error: {error}", + } + + async def messages_with_tools( + self, + messages: List[Dict[str, Any]], + tools: List[Dict[str, Any]], + model: str = "claude-3-5-sonnet-20241022" + ) -> Dict[str, Any]: + """ + Messages API with tool use, routing to MCP tools with passport + + This demonstrates the full flow: + 1. Anthropic decides to use a tool + 2. We route it to MCP tool with agent_id attached + 3. MCP server verifies passport via APort + 4. Result is returned to Anthropic + """ + if not self.anthropic_client: + raise RuntimeError("Anthropic client not initialized") + + if not self.mcp_client: + await self.initialize_mcp() + + # Make initial messages request + response = self.anthropic_client.messages.create( + model=model, + max_tokens=1024, + messages=messages, + tools=tools, + ) + + # Process tool use requests + if response.stop_reason == "tool_use": + tool_results = [] + + for content in response.content: + if content.type == "tool_use": + # Handle tool use via MCP with passport + tool_result = await self.handle_tool_use({ + "id": content.id, + "name": content.name, + "input": content.input, + }) + tool_results.append(tool_result) + + # Add tool results to messages + messages.append({ + "role": "assistant", + "content": response.content, + }) + messages.append({ + "role": "user", + "content": tool_results, + }) + + # Get final response + final_response = self.anthropic_client.messages.create( + model=model, + max_tokens=1024, + messages=messages, + tools=tools, + ) + + return final_response + + return response + + +async def example_anthropic_merge(): + """Example: Merge PR via Anthropic tool use""" + print("=" * 60) + print("Example: Anthropic Tool Use with MCP Passport") + print("=" * 60) + + wrapper = AnthropicWithMCPPassport(AGENT_ID) + + try: + # Define tools available to Anthropic + tools = [ + { + "name": "merge_pull_request", + "description": "Merge a pull request to a branch", + "input_schema": { + "type": "object", + "properties": { + "repository": {"type": "string", "description": "Repository name (owner/repo)"}, + "pr_number": {"type": "integer", "description": "Pull request number"}, + "base_branch": {"type": "string", "description": "Base branch (e.g., main)"}, + }, + "required": ["repository", "pr_number"], + }, + } + ] + + # User request + messages = [ + { + "role": "user", + "content": "Merge PR #123 in my-org/my-repo to main branch" + } + ] + + # Messages with tool use + response = await wrapper.messages_with_tools( + messages=messages, + tools=tools, + ) + + print("✅ Anthropic response:", response.content[0].text) + + except Exception as error: + print(f"❌ Error: {error}") + finally: + await wrapper.close() + + +async def main(): + """Main example""" + if not ANTHROPIC_AVAILABLE: + print("⚠️ Anthropic SDK not available. Install with: pip install anthropic") + return + + if not ANTHROPIC_API_KEY: + print("⚠️ ANTHROPIC_API_KEY environment variable not set") + return + + await example_anthropic_merge() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/client_example.py b/client_example.py new file mode 100644 index 0000000..397eaa4 --- /dev/null +++ b/client_example.py @@ -0,0 +1,470 @@ +""" +MCP Client with Passport Example + +This example demonstrates how to attach agent passports to MCP tool calls +for authorization verification. This is the CLIENT side - the agent that +makes tool calls to MCP servers. + +Key concepts: +1. Attach agent_id to MCP tool call arguments +2. Handle policy denials gracefully (retry with lower request, or escalate) +3. Passport renewal flow when passport expires +4. Error handling and audit trails + +This works with any MCP server that requires agent_id for policy verification. +""" + +import asyncio +import os +import json +import time +from typing import Any, Dict, Optional, List +# MCP SDK imports (install: pip install mcp) +try: + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client +except ImportError: + print("⚠️ MCP SDK not installed. Install with: pip install mcp") + print(" For now, this example shows the pattern without actual MCP calls") + ClientSession = None + stdio_client = None + +# APort SDK imports +try: + from aporthq_sdk_python import APortClient, APortClientOptions +except ImportError: + print("⚠️ APort SDK not installed. Install with: pip install aporthq-sdk-python") + APortClient = None + APortClientOptions = None + + +# Configuration +AGENT_ID = os.getenv("APORT_AGENT_ID", "ap_a2d10232c6534523812423eec8a1425c") +APORT_BASE_URL = os.getenv("APORT_BASE_URL", "https://api.aport.io") +MCP_SERVER_COMMAND = os.getenv("MCP_SERVER_COMMAND", "npx") +MCP_SERVER_ARGS = os.getenv("MCP_SERVER_ARGS", "@aporthq/mcp-policy-gate-example").split() + + +class PolicyDeniedError(Exception): + """Raised when a policy denies a tool call""" + def __init__(self, message: str, result: Any = None): + super().__init__(message) + self.result = result + + +class MCPClientWithPassport: + """ + MCP Client with Passport Support + + Wraps the MCP client to automatically attach agent_id to tool calls + """ + + def __init__(self, agent_id: str, server_params: Optional[StdioServerParameters] = None): + self.agent_id = agent_id + self.server_params = server_params + self.session: Optional[ClientSession] = None + + # Initialize APort client + if APortClient: + self.aport_client = APortClient(APortClientOptions( + base_url=APORT_BASE_URL, + timeout_ms=5000, + )) + else: + self.aport_client = None + + async def __aenter__(self): + """Async context manager entry""" + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit""" + await self.close() + + async def connect(self): + """Connect to MCP server""" + if not stdio_client: + raise ImportError("MCP SDK not installed. Install with: pip install mcp") + + if not self.server_params: + # Default server params + self.server_params = StdioServerParameters( + command=MCP_SERVER_COMMAND, + args=MCP_SERVER_ARGS, + ) + + # Connect to MCP server + async with stdio_client(self.server_params) as (read, write): + async with ClientSession(read, write) as session: + self.session = session + print(f"[MCP Client] Connected to MCP server with agent_id: {self.agent_id}") + + def _get_policy_id_for_tool(self, tool_name: str) -> str: + """Map MCP tool name to APort policy ID""" + tool_to_policy_map = { + "merge_pull_request": "code.repository.merge.v1", + "process_refund": "finance.payment.refund.v1", + "export_customer_data": "data.export.create.v1", + "publish_release": "code.release.publish.v1", + "send_message": "messaging.message.send.v1", + "execute_transaction": "finance.transaction.execute.v1", + "access_data": "governance.data.access.v1", + "crypto_trade": "finance.crypto.trade.v1", + "ingest_report": "data.report.ingest.v1", + "review_contract": "legal.contract.review.v1", + } + + policy_id = tool_to_policy_map.get(tool_name) + if not policy_id: + available = ", ".join(tool_to_policy_map.keys()) + raise ValueError( + f"No policy mapping found for tool: {tool_name}. " + f"Available tools: {available}" + ) + return policy_id + + def _build_policy_context(self, tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Build context for policy verification from tool arguments""" + context: Dict[str, Any] = { + "agent_id": self.agent_id, + **args, + } + + # Add tool-specific context transformations + if tool_name == "merge_pull_request": + context["base_branch"] = args.get("base_branch", "main") + context["pr_size_kb"] = args.get("pr_size_kb", 250) + elif tool_name == "process_refund": + context["reason_code"] = args.get("reason_code", "customer_request") + + return context + + async def call_tool( + self, + tool_name: str, + args: Dict[str, Any], + retry_on_denial: bool = False, + max_retries: int = 3, + retry_backoff: float = 1.0, + skip_verification: bool = False, # For testing or when server handles verification + ) -> Any: + """ + Call MCP tool with automatic policy verification and agent_id attachment + + Args: + tool_name: Name of the tool to call + args: Tool arguments (agent_id will be added automatically) + retry_on_denial: Whether to retry with adjusted parameters on denial + max_retries: Maximum number of retry attempts + retry_backoff: Backoff delay in seconds between retries + skip_verification: Skip client-side verification (server handles it) + """ + if not self.aport_client: + raise RuntimeError("APort client not initialized") + + last_error: Optional[Exception] = None + current_args = args.copy() + + for attempt in range(max_retries): + try: + # Step 1: Verify policy BEFORE calling MCP tool (unless skipped) + if not skip_verification: + policy_id = self._get_policy_id_for_tool(tool_name) + context = self._build_policy_context(tool_name, current_args) + + print( + f"[Policy Verification] Verifying {policy_id} for agent {self.agent_id} " + f"(attempt {attempt + 1}/{max_retries})" + ) + + decision = await self.aport_client.verify_policy( + self.agent_id, + policy_id, + context, + ) + + print( + f"[Policy Decision] {decision.decision_id}: " + f"{'ALLOW' if decision.allow else 'DENY'}" + ) + + if not decision.allow: + reasons = ( + ", ".join(r.message for r in decision.reasons) + if decision.reasons + else "Policy denied" + ) + raise PolicyDeniedError(f"Policy denied: {reasons}", decision) + + print( + f"[Policy Verification] ✅ Policy check passed " + f"(decision_id: {decision.decision_id})" + ) + + # Step 2: Call MCP tool with agent_id attached + print(f"[Tool Call] Calling {tool_name} (attempt {attempt + 1}/{max_retries})") + + if not self.session: + raise RuntimeError("Not connected to MCP server") + + # Attach agent_id to arguments for MCP server + args_with_passport = { + **current_args, + "agent_id": self.agent_id, + } + + # Call tool via MCP + result = await self.session.call_tool(tool_name, args_with_passport) + + # Check if result indicates policy denial (server-side check) + if result.content: + for content in result.content: + if isinstance(content, dict) and content.get("type") == "text": + text = content.get("text", "") + if "Policy denied" in text: + raise PolicyDeniedError(text, result) + + print(f"[Tool Call] ✅ {tool_name} succeeded") + return result + + except PolicyDeniedError as error: + last_error = error + + # If retry is enabled, try with adjusted parameters + if retry_on_denial and attempt < max_retries - 1: + print(f"[Tool Call] ❌ Policy denied, retrying with adjusted parameters...") + + # Example: Reduce amount for refunds, reduce row limit for exports + if tool_name == "process_refund" and "amount" in current_args: + current_args["amount"] = int(current_args["amount"] * 0.5) # Reduce by 50% + print(f"[Tool Call] Retrying with reduced amount: {current_args['amount']}") + elif tool_name == "export_customer_data" and "limit" in current_args: + current_args["limit"] = int(current_args["limit"] * 0.5) # Reduce by 50% + print(f"[Tool Call] Retrying with reduced limit: {current_args['limit']}") + + # Wait before retry + await asyncio.sleep(retry_backoff * (attempt + 1)) + continue + + # If not retryable or max retries reached, raise + raise + + except Exception as error: + last_error = error + if attempt < max_retries - 1: + await asyncio.sleep(retry_backoff * (attempt + 1)) + continue + raise + + raise last_error or Exception(f"Failed to call {tool_name} after {max_retries} attempts") + + async def list_tools(self) -> List[Dict[str, Any]]: + """List available tools from MCP server""" + if not self.session: + raise RuntimeError("Not connected to MCP server") + + result = await self.session.list_tools() + return result.tools or [] + + async def close(self): + """Close connection""" + if self.session: + # Session is closed automatically by context manager + pass + if self.aport_client: + await self.aport_client.close() + + +async def example_with_openai(): + """ + Example: Using MCP Client with OpenAI Function Calling + + This shows how to integrate MCP client with OpenAI's function calling API + """ + print("=" * 60) + print("Example: MCP Client with OpenAI Function Calling") + print("=" * 60) + + # In a real OpenAI integration, you would: + # 1. Get function call from OpenAI + # 2. Map function name to MCP tool name + # 3. Call MCP tool with agent_id attached + # 4. Return result to OpenAI + + async with MCPClientWithPassport(AGENT_ID) as mcp_client: + try: + # Simulate OpenAI function call: "refund $50 to customer_123" + openai_function_call = { + "name": "process_refund", + "arguments": { + "amount": 5000, # $50.00 in cents + "currency": "USD", + "order_id": "ord_123", + "customer_id": "customer_123", + "reason_code": "customer_request", + }, + } + + # Call MCP tool with passport attached + result = await mcp_client.call_tool( + openai_function_call["name"], + openai_function_call["arguments"], + retry_on_denial=True, + max_retries=3, + ) + + print("✅ Refund processed:", result) + + except PolicyDeniedError as error: + print(f"❌ Policy denied: {error}") + print(f" Result: {error.result}") + # In a real OpenAI integration, you would return this to the user + except Exception as error: + print(f"❌ Error: {error}") + + +async def example_with_anthropic(): + """ + Example: Using MCP Client with Anthropic Tool Use + + This shows how to integrate MCP client with Anthropic's tool use API + """ + print("=" * 60) + print("Example: MCP Client with Anthropic Tool Use") + print("=" * 60) + + async with MCPClientWithPassport(AGENT_ID) as mcp_client: + try: + # Simulate Anthropic tool use: "merge PR #123" + anthropic_tool_use = { + "id": "toolu_abc123", + "name": "merge_pull_request", + "input": { + "repository": "my-org/my-repo", + "pr_number": 123, + "base_branch": "main", + }, + } + + # Call MCP tool with passport attached + result = await mcp_client.call_tool( + anthropic_tool_use["name"], + anthropic_tool_use["input"], + retry_on_denial=False, # Don't retry merges + ) + + print("✅ PR merged:", result) + + except PolicyDeniedError as error: + print(f"❌ Policy denied: {error}") + # In a real Anthropic integration, you would return this to the model + except Exception as error: + print(f"❌ Error: {error}") + + +async def example_policy_verification(): + """ + Example: Policy Verification Flow + + Demonstrates how policy verification works before tool execution + """ + print("=" * 60) + print("Example: Policy Verification Flow") + print("=" * 60) + + async with MCPClientWithPassport(AGENT_ID) as mcp_client: + try: + # First call - policy is verified before tool execution + print("Call 1: Policy verification before tool execution") + result1 = await mcp_client.call_tool("merge_pull_request", { + "repository": "my-org/my-repo", + "pr_number": 1, + }) + print(f"✅ First call succeeded: {result1}") + + # Second call - policy is verified again (fresh verification each time) + print("\nCall 2: Policy verification again (fresh check)") + result2 = await mcp_client.call_tool("merge_pull_request", { + "repository": "my-org/my-repo", + "pr_number": 2, + }) + print(f"✅ Second call succeeded: {result2}") + + print("\n✅ Policy verification flow completed") + print(" Note: Each tool call verifies policy before execution") + + except PolicyDeniedError as error: + print(f"❌ Policy denied: {error}") + if hasattr(error, 'result') and error.result: + print(f" Decision ID: {getattr(error.result, 'decision_id', 'N/A')}") + print(f" Reasons: {getattr(error.result, 'reasons', 'N/A')}") + except Exception as error: + print(f"❌ Error: {error}") + + +async def example_error_handling(): + """ + Example: Error Handling and Graceful Degradation + + Shows how to handle different error scenarios + """ + print("=" * 60) + print("Example: Error Handling") + print("=" * 60) + + async with MCPClientWithPassport(AGENT_ID) as mcp_client: + # Example 1: Policy denial with retry + print("\n1. Policy denial with automatic retry:") + try: + await mcp_client.call_tool( + "process_refund", + { + "amount": 1000000, # $10,000 - might exceed limits + "currency": "USD", + "order_id": "ord_456", + }, + retry_on_denial=True, + max_retries=3, + ) + except PolicyDeniedError: + print(" Policy denied after retries - escalate to human") + except Exception as error: + print(f" Error: {error}") + + # Example 2: Invalid tool name + print("\n2. Invalid tool name:") + try: + await mcp_client.call_tool("nonexistent_tool", {}) + except Exception as error: + print(f" Error: {error}") + + # Example 3: Network error + print("\n3. Network error handling:") + # In production, you would implement retry logic with exponential backoff + # and circuit breaker pattern + + +async def main(): + """Main example runner""" + print("🚀 MCP Client with Passport Examples\n") + + # Run examples + await example_with_openai() + print("\n") + + await example_with_anthropic() + print("\n") + + await example_policy_verification() + print("\n") + + await example_error_handling() + print("\n") + + print("✨ All examples completed!") + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/openai-integration-example.py b/openai-integration-example.py new file mode 100644 index 0000000..73caaa4 --- /dev/null +++ b/openai-integration-example.py @@ -0,0 +1,247 @@ +""" +OpenAI Function Calling with MCP and APort Passport + +This example shows how to integrate MCP client with OpenAI's function calling API, +automatically attaching agent passports for authorization. + +Prerequisites: + pip install openai + pip install aporthq-sdk-python + pip install mcp # Optional, for direct MCP integration +""" + +import os +import json +import asyncio +from typing import Dict, Any, List, Optional + +try: + from openai import OpenAI + OPENAI_AVAILABLE = True +except ImportError: + OPENAI_AVAILABLE = False + print("⚠️ OpenAI SDK not installed. Install with: pip install openai") + +from aporthq_sdk_python import APortClient, APortClientOptions +from client_example import MCPClientWithPassport, PolicyDeniedError + + +# Configuration +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +AGENT_ID = os.getenv("APORT_AGENT_ID", "ap_a2d10232c6534523812423eec8a1425c") +APORT_BASE_URL = os.getenv("APORT_BASE_URL", "https://api.aport.io") + + +class OpenAIWithMCPPassport: + """ + OpenAI client wrapper that integrates MCP tools with passport support + """ + + def __init__(self, agent_id: str, openai_client: Optional[OpenAI] = None): + self.agent_id = agent_id + self.openai_client = openai_client or (OpenAI(api_key=OPENAI_API_KEY) if OPENAI_AVAILABLE else None) + self.mcp_client: Optional[MCPClientWithPassport] = None + self.aport_client = APortClient(APortClientOptions(base_url=APORT_BASE_URL)) + + async def initialize_mcp(self): + """Initialize MCP client connection""" + self.mcp_client = MCPClientWithPassport(self.agent_id) + await self.mcp_client.connect() + + async def close(self): + """Close connections""" + if self.mcp_client: + await self.mcp_client.close() + await self.aport_client.close() + + def _map_openai_function_to_mcp_tool(self, function_name: str) -> str: + """ + Map OpenAI function name to MCP tool name + + In a real implementation, you would maintain a mapping of + OpenAI function names to MCP tool names. + """ + mapping = { + "process_refund": "process_refund", + "merge_pull_request": "merge_pull_request", + "export_customer_data": "export_customer_data", + } + return mapping.get(function_name, function_name) + + async def handle_function_call( + self, + function_name: str, + arguments: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Handle OpenAI function call by routing to MCP tool with passport + + This is called when OpenAI requests a function execution. + """ + if not self.mcp_client: + await self.initialize_mcp() + + # Map OpenAI function to MCP tool + mcp_tool_name = self._map_openai_function_to_mcp_tool(function_name) + + try: + # Call MCP tool with passport attached + result = await self.mcp_client.call_tool( + mcp_tool_name, + arguments, + retry_on_denial=True, + max_retries=3, + ) + + # Format result for OpenAI + if result.content: + text_content = next( + (c.get("text", "") for c in result.content if c.get("type") == "text"), + "" + ) + return { + "role": "function", + "name": function_name, + "content": text_content, + } + + return { + "role": "function", + "name": function_name, + "content": "Tool executed successfully", + } + + except PolicyDeniedError as error: + # Return policy denial to OpenAI + return { + "role": "function", + "name": function_name, + "content": f"Policy denied: {error}", + } + except Exception as error: + return { + "role": "function", + "name": function_name, + "content": f"Error: {error}", + } + + async def chat_completion_with_tools( + self, + messages: List[Dict[str, Any]], + functions: List[Dict[str, Any]], + model: str = "gpt-4" + ) -> Dict[str, Any]: + """ + Chat completion with function calling, routing to MCP tools with passport + + This demonstrates the full flow: + 1. OpenAI decides to call a function + 2. We route it to MCP tool with agent_id attached + 3. MCP server verifies passport via APort + 4. Result is returned to OpenAI + """ + if not self.openai_client: + raise RuntimeError("OpenAI client not initialized") + + if not self.mcp_client: + await self.initialize_mcp() + + # Make initial chat completion request + response = self.openai_client.chat.completions.create( + model=model, + messages=messages, + functions=functions, + function_call="auto", + ) + + # Process function calls + messages.append(response.choices[0].message.model_dump()) + + # If function call was requested, execute it + if response.choices[0].message.function_call: + function_call = response.choices[0].message.function_call + function_name = function_call.name + arguments = json.loads(function_call.arguments) + + # Handle function call via MCP with passport + function_result = await self.handle_function_call(function_name, arguments) + messages.append(function_result) + + # Get final response + final_response = self.openai_client.chat.completions.create( + model=model, + messages=messages, + ) + + return final_response + + return response + + +async def example_openai_refund(): + """Example: Process refund via OpenAI function calling""" + print("=" * 60) + print("Example: OpenAI Function Calling with MCP Passport") + print("=" * 60) + + wrapper = OpenAIWithMCPPassport(AGENT_ID) + + try: + # Define functions available to OpenAI + functions = [ + { + "name": "process_refund", + "description": "Process a refund for a customer. Amount must be in cents.", + "parameters": { + "type": "object", + "properties": { + "amount": {"type": "integer", "description": "Amount in cents"}, + "currency": {"type": "string", "description": "Currency code (USD, EUR, etc.)"}, + "order_id": {"type": "string", "description": "Order ID"}, + "customer_id": {"type": "string", "description": "Customer ID"}, + "reason_code": {"type": "string", "description": "Reason for refund"}, + }, + "required": ["amount", "currency", "order_id", "customer_id"], + }, + } + ] + + # User request + messages = [ + { + "role": "user", + "content": "Refund $50 to customer_123 for order ord_456" + } + ] + + # Chat completion with function calling + response = await wrapper.chat_completion_with_tools( + messages=messages, + functions=functions, + ) + + print("✅ OpenAI response:", response.choices[0].message.content) + + except Exception as error: + print(f"❌ Error: {error}") + finally: + await wrapper.close() + + +async def main(): + """Main example""" + if not OPENAI_AVAILABLE: + print("⚠️ OpenAI SDK not available. Install with: pip install openai") + return + + if not OPENAI_API_KEY: + print("⚠️ OPENAI_API_KEY environment variable not set") + return + + await example_openai_refund() + + +if __name__ == "__main__": + import json + asyncio.run(main()) + diff --git a/package.json b/package.json index 660aa48..65dd2ea 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,13 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", - "dev": "tsx src/index.ts" + "dev": "tsx src/index.ts", + "client": "tsx src/client-example.ts", + "client:build": "tsc && node dist/client-example.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", - "@aporthq/sdk-node": "^0.1.0" + "@aporthq/sdk-node": "^0.1.3" }, "devDependencies": { "typescript": "^5.0.0", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c7f576 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# APort SDK (published to PyPI) +aporthq-sdk-python>=0.1.1 + +# MCP SDK (optional, for direct MCP integration) +# mcp>=0.1.0 + +# OpenAI SDK (for OpenAI integration example) +openai>=1.0.0 + +# Anthropic SDK (for Anthropic integration example) +anthropic>=0.18.0 + +# Async support +aiohttp>=3.9.0 + diff --git a/src/client-example.ts b/src/client-example.ts new file mode 100644 index 0000000..17812be --- /dev/null +++ b/src/client-example.ts @@ -0,0 +1,547 @@ +/** + * MCP Client with Passport Example + * + * This example demonstrates how to attach agent passports to MCP tool calls + * for authorization verification. This is the CLIENT side - the agent that + * makes tool calls to MCP servers. + * + * Key concepts: + * 1. Attach agent_id to MCP tool call arguments + * 2. Handle policy denials gracefully (retry with lower request, or escalate) + * 3. Passport renewal flow when passport expires + * 4. Error handling and audit trails + * + * This works with any MCP server that requires agent_id for policy verification. + */ + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { APortClient } from "@aporthq/sdk-node"; +import type { PolicyVerificationResponse } from "@aporthq/sdk-node"; + +// Configuration +const AGENT_ID = + process.env.APORT_AGENT_ID || "ap_a2d10232c6534523812423eec8a1425c"; +const APORT_BASE_URL = process.env.APORT_BASE_URL || "https://api.aport.io"; +const MCP_SERVER_COMMAND = process.env.MCP_SERVER_COMMAND || "npx"; +const MCP_SERVER_ARGS = process.env.MCP_SERVER_ARGS + ? process.env.MCP_SERVER_ARGS.split(" ") + : ["@aporthq/mcp-policy-gate-example"]; + +/** + * MCP Client with Passport Support + * + * Wraps the MCP client to automatically attach agent_id to tool calls + */ +class MCPClientWithPassport { + private client: Client; + private agentId: string; + private aportClient: APortClient; + + constructor(agentId: string, transport: StdioClientTransport) { + this.agentId = agentId; + this.client = new Client( + { + name: "mcp-client-with-passport", + version: "1.0.0", + }, + { + capabilities: {}, + } + ); + this.aportClient = new APortClient({ + baseUrl: APORT_BASE_URL, + timeoutMs: 5000, + }); + } + + /** + * Connect to MCP server + */ + async connect(transport: StdioClientTransport): Promise { + await this.client.connect(transport); + console.error( + `[MCP Client] Connected to MCP server with agent_id: ${this.agentId}` + ); + } + + /** + * Map MCP tool name to APort policy ID + */ + private getPolicyIdForTool(toolName: string): string { + const toolToPolicyMap: Record = { + merge_pull_request: "code.repository.merge.v1", + process_refund: "finance.payment.refund.v1", + export_customer_data: "data.export.create.v1", + publish_release: "code.release.publish.v1", + send_message: "messaging.message.send.v1", + execute_transaction: "finance.transaction.execute.v1", + access_data: "governance.data.access.v1", + crypto_trade: "finance.crypto.trade.v1", + ingest_report: "data.report.ingest.v1", + review_contract: "legal.contract.review.v1", + }; + + const policyId = toolToPolicyMap[toolName]; + if (!policyId) { + throw new Error( + `No policy mapping found for tool: ${toolName}. Available tools: ${Object.keys( + toolToPolicyMap + ).join(", ")}` + ); + } + return policyId; + } + + /** + * Build context for policy verification from tool arguments + */ + private buildPolicyContext( + toolName: string, + args: Record + ): Record { + const context: Record = { + agent_id: this.agentId, + ...args, + }; + + // Add tool-specific context transformations + if (toolName === "merge_pull_request") { + context.base_branch = args.base_branch || "main"; + context.pr_size_kb = args.pr_size_kb || 250; + } else if (toolName === "process_refund") { + context.reason_code = args.reason_code || "customer_request"; + } + + return context; + } + + /** + * Call MCP tool with automatic policy verification and agent_id attachment + */ + async callTool( + toolName: string, + args: Record, + options?: { + retryOnDenial?: boolean; + maxRetries?: number; + retryBackoff?: number; + skipVerification?: boolean; // For testing or when server handles verification + } + ): Promise { + const maxRetries = options?.maxRetries ?? 3; + const retryBackoff = options?.retryBackoff ?? 1000; + let lastError: Error | null = null; + let currentArgs = { ...args }; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + // Step 1: Verify policy BEFORE calling MCP tool (unless skipped) + if (!options?.skipVerification) { + const policyId = this.getPolicyIdForTool(toolName); + const context = this.buildPolicyContext(toolName, currentArgs); + + console.error( + `[Policy Verification] Verifying ${policyId} for agent ${ + this.agentId + } (attempt ${attempt + 1}/${maxRetries})` + ); + + const decision: PolicyVerificationResponse = + await this.aportClient.verifyPolicy( + this.agentId, + policyId, + context + ); + + console.error( + `[Policy Decision] ${decision.decision_id}: ${ + decision.allow ? "ALLOW" : "DENY" + }` + ); + + if (!decision.allow) { + const reasons = + decision.reasons?.map((r) => r.message).join(", ") || + "Policy denied"; + throw new PolicyDeniedError(`Policy denied: ${reasons}`, decision); + } + + console.error( + `[Policy Verification] ✅ Policy check passed (decision_id: ${decision.decision_id})` + ); + } + + // Step 2: Call MCP tool with agent_id attached + console.error( + `[Tool Call] Calling ${toolName} (attempt ${ + attempt + 1 + }/${maxRetries})` + ); + + // Attach agent_id to arguments for MCP server + const argsWithPassport: Record = { + ...currentArgs, + agent_id: this.agentId, + }; + + const result: any = await (this.client as any).request({ + method: "tools/call", + params: { + name: toolName, + arguments: argsWithPassport, + }, + }); + + // Check if result indicates policy denial (server-side check) + if (result && result.content && Array.isArray(result.content)) { + const textContent = result.content.find( + (c: any) => c.type === "text" + ); + if (textContent?.text?.includes("Policy denied")) { + throw new PolicyDeniedError(textContent.text, result); + } + } + + console.error(`[Tool Call] ✅ ${toolName} succeeded`); + return result; + } catch (error) { + lastError = error as Error; + + // If it's a policy denial and retry is enabled, try with adjusted parameters + if ( + error instanceof PolicyDeniedError && + options?.retryOnDenial && + attempt < maxRetries - 1 + ) { + console.error( + `[Tool Call] ❌ Policy denied, retrying with adjusted parameters...` + ); + + // Example: Reduce amount for refunds, reduce row limit for exports + if (toolName === "process_refund" && currentArgs.amount) { + currentArgs.amount = Math.floor( + (currentArgs.amount as number) * 0.5 + ); // Reduce by 50% + console.error( + `[Tool Call] Retrying with reduced amount: ${currentArgs.amount}` + ); + } else if (toolName === "export_customer_data" && currentArgs.limit) { + currentArgs.limit = Math.floor((currentArgs.limit as number) * 0.5); // Reduce by 50% + console.error( + `[Tool Call] Retrying with reduced limit: ${currentArgs.limit}` + ); + } + + // Wait before retry + await new Promise((resolve) => + setTimeout(resolve, retryBackoff * (attempt + 1)) + ); + continue; + } + + // If not retryable or max retries reached, throw + throw error; + } + } + + throw ( + lastError || + new Error(`Failed to call ${toolName} after ${maxRetries} attempts`) + ); + } + + /** + * List available tools from MCP server + */ + async listTools(): Promise { + const result: any = await (this.client as any).request({ + method: "tools/list", + params: {}, + }); + return result && result.tools ? result.tools : []; + } + + /** + * Close connection + */ + async close(): Promise { + await this.client.close(); + } +} + +/** + * Policy Denial Error + */ +class PolicyDeniedError extends Error { + constructor( + message: string, + public result: PolicyVerificationResponse | any + ) { + super(message); + this.name = "PolicyDeniedError"; + } + + get decisionId(): string | undefined { + if (this.result && typeof this.result === "object") { + return this.result.decision_id; + } + return undefined; + } + + get reasons(): Array<{ code: string; message: string }> | undefined { + if (this.result && typeof this.result === "object") { + return this.result.reasons; + } + return undefined; + } +} + +/** + * Example: Using MCP Client with OpenAI Function Calling + * + * This shows how to integrate MCP client with OpenAI's function calling API + */ +export async function exampleWithOpenAI() { + console.log("=".repeat(60)); + console.log("Example: MCP Client with OpenAI Function Calling"); + console.log("=".repeat(60)); + + // In a real OpenAI integration, you would: + // 1. Get function call from OpenAI + // 2. Map function name to MCP tool name + // 3. Call MCP tool with agent_id attached + // 4. Return result to OpenAI + + const transport = new StdioClientTransport({ + command: MCP_SERVER_COMMAND, + args: MCP_SERVER_ARGS, + }); + + const mcpClient = new MCPClientWithPassport(AGENT_ID, transport); + await mcpClient.connect(transport); + + try { + // Simulate OpenAI function call: "refund $50 to customer_123" + const openaiFunctionCall = { + name: "process_refund", + arguments: { + amount: 5000, // $50.00 in cents + currency: "USD", + order_id: "ord_123", + customer_id: "customer_123", + reason_code: "customer_request", + }, + }; + + // Call MCP tool with passport attached + const result = await mcpClient.callTool( + openaiFunctionCall.name, + openaiFunctionCall.arguments, + { + retryOnDenial: true, + maxRetries: 3, + } + ); + + console.log("✅ Refund processed:", result); + } catch (error) { + if (error instanceof PolicyDeniedError) { + console.error("❌ Policy denied:", error.message); + console.error(" Result:", error.result); + // In a real OpenAI integration, you would return this to the user + } else { + console.error("❌ Error:", error); + } + } finally { + await mcpClient.close(); + } +} + +/** + * Example: Using MCP Client with Anthropic Tool Use + * + * This shows how to integrate MCP client with Anthropic's tool use API + */ +export async function exampleWithAnthropic() { + console.log("=".repeat(60)); + console.log("Example: MCP Client with Anthropic Tool Use"); + console.log("=".repeat(60)); + + const transport = new StdioClientTransport({ + command: MCP_SERVER_COMMAND, + args: MCP_SERVER_ARGS, + }); + + const mcpClient = new MCPClientWithPassport(AGENT_ID, transport); + await mcpClient.connect(transport); + + try { + // Simulate Anthropic tool use: "merge PR #123" + const anthropicToolUse = { + id: "toolu_abc123", + name: "merge_pull_request", + input: { + repository: "my-org/my-repo", + pr_number: 123, + base_branch: "main", + }, + }; + + // Call MCP tool with passport attached + const result = await mcpClient.callTool( + anthropicToolUse.name, + anthropicToolUse.input, + { + retryOnDenial: false, // Don't retry merges + } + ); + + console.log("✅ PR merged:", result); + } catch (error) { + if (error instanceof PolicyDeniedError) { + console.error("❌ Policy denied:", error.message); + // In a real Anthropic integration, you would return this to the model + } else { + console.error("❌ Error:", error); + } + } finally { + await mcpClient.close(); + } +} + +/** + * Example: Policy Verification Flow + * + * Demonstrates how policy verification works before tool execution + */ +export async function examplePolicyVerification() { + console.log("=".repeat(60)); + console.log("Example: Policy Verification Flow"); + console.log("=".repeat(60)); + + const transport = new StdioClientTransport({ + command: MCP_SERVER_COMMAND, + args: MCP_SERVER_ARGS, + }); + + const mcpClient = new MCPClientWithPassport(AGENT_ID, transport); + await mcpClient.connect(transport); + + try { + // First call - policy is verified before tool execution + console.log("Call 1: Policy verification before tool execution"); + const result1 = await mcpClient.callTool("merge_pull_request", { + repository: "my-org/my-repo", + pr_number: 1, + }); + console.log("✅ First call succeeded:", result1); + + // Second call - policy is verified again (fresh verification each time) + console.log("\nCall 2: Policy verification again (fresh check)"); + const result2 = await mcpClient.callTool("merge_pull_request", { + repository: "my-org/my-repo", + pr_number: 2, + }); + console.log("✅ Second call succeeded:", result2); + + console.log("\n✅ Policy verification flow completed"); + console.log(" Note: Each tool call verifies policy before execution"); + } catch (error) { + if (error instanceof PolicyDeniedError) { + console.error("❌ Policy denied:", error.message); + console.error(" Decision ID:", error.decisionId); + console.error(" Reasons:", error.reasons); + } else { + console.error("❌ Error:", error); + } + } finally { + await mcpClient.close(); + } +} + +/** + * Example: Error Handling and Graceful Degradation + * + * Shows how to handle different error scenarios + */ +export async function exampleErrorHandling() { + console.log("=".repeat(60)); + console.log("Example: Error Handling"); + console.log("=".repeat(60)); + + const transport = new StdioClientTransport({ + command: MCP_SERVER_COMMAND, + args: MCP_SERVER_ARGS, + }); + + const mcpClient = new MCPClientWithPassport(AGENT_ID, transport); + await mcpClient.connect(transport); + + // Example 1: Policy denial with retry + console.log("\n1. Policy denial with automatic retry:"); + try { + await mcpClient.callTool( + "process_refund", + { + amount: 1000000, // $10,000 - might exceed limits + currency: "USD", + order_id: "ord_456", + }, + { + retryOnDenial: true, + maxRetries: 3, + } + ); + } catch (error) { + if (error instanceof PolicyDeniedError) { + console.log(" Policy denied after retries - escalate to human"); + } + } + + // Example 2: Invalid tool name + console.log("\n2. Invalid tool name:"); + try { + await mcpClient.callTool("nonexistent_tool", {}); + } catch (error) { + console.log( + ` Error: ${error instanceof Error ? error.message : String(error)}` + ); + } + + // Example 3: Network error + console.log("\n3. Network error handling:"); + // In production, you would implement retry logic with exponential backoff + // and circuit breaker pattern + + await mcpClient.close(); +} + +/** + * Main example runner + */ +async function main() { + console.log("🚀 MCP Client with Passport Examples\n"); + + // Run examples + await exampleWithOpenAI(); + console.log("\n"); + + await exampleWithAnthropic(); + console.log("\n"); + + await examplePolicyVerification(); + console.log("\n"); + + await exampleErrorHandling(); + console.log("\n"); + + console.log("✨ All examples completed!"); +} + +// Run if executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(console.error); +} + +export { MCPClientWithPassport, PolicyDeniedError };