Browse Source

update cli and agent

david 1 month ago
parent
commit
fb05d8198f

+ 6 - 0
arp_agent/.env.example

@@ -37,6 +37,12 @@ ARP_MAX_QUEUE_SIZE=100
 # Default: 10
 ARP_MAX_ITERATIONS=10
 
+# MCP Configuration (optional)
+# Path to MCP server configuration JSON file
+# This allows the agent to connect to external MCP servers for additional tools
+# Example: /path/to/mcp.json
+# MCP_CONFIG_PATH=/path/to/mcp.json
+
 # Example configurations for different roles:
 # HR Specialist:
 # ARP_AGENT_NAME=HR Bot

+ 0 - 636
arp_agent/README.MD

@@ -1,636 +0,0 @@
-# ARP Agent - LLM Agent for the ARP Platform
-## An LLM-powered agent that connects to the ARP (Agent-native ERP) platform via the Model Context Protocol (MCP) and responds to Task and Message events in real-time.
-
-
-## Table of Contents
-
-- [Overview](#overview)
-- [Installation](#installation)
-- [Configuration](#configuration)
-- [Running the Agent](#running-the-agent)
-- [How It Works](#how-it-works)
-- [MCP Communication](#mcp-communication)
-- [Available MCP Tools](#available-mcp-tools)
-- [Workflows](#workflows)
-- [Programmatic Usage](#programmatic-usage)
-- [Testing](#testing)
-
----
-
-## Overview
-
-The ARP Agent connects to an ARP server and:
-
-1. **Authenticates** via GraphQL login to obtain a JWT token
-2. **Connects to MCP** via Server-Sent Events (SSE) to the `/mcp` endpoint
-3. **Discovers Tools** using the MCP `tools/list` protocol
-4. **Subscribes to Events** via MCP resources for real-time notifications
-5. **Processes Events** using an LLM with tool-calling capabilities
-
----
-
-## Installation
-
-### Using pip
-
-```bash
-pip install -r requirements.txt
-```
-
-### Using Poetry (recommended)
-
-```bash
-poetry install
-```
-
-### Dependencies
-
-- `openai` - OpenAI API client for LLM interactions
-- `requests` - HTTP client for GraphQL and MCP communication
-- `sseclient-py` - Server-Sent Events client for MCP
-- `python-dotenv` - Environment variable management
-
----
-
-## Configuration
-
-Copy the example environment file and configure your credentials:
-
-```bash
-cp .env.example .env
-```
-
-Edit `.env` with your settings:
-
-```env
-# ARP Server Configuration
-ARP_URL=http://localhost:8080
-ARP_USERNAME=your-email@example.com
-ARP_PASSWORD=your-password
-
-# OpenAI Configuration
-OPENAI_API_KEY=sk-your-openai-api-key
-OPENAI_MODEL=gpt-4
-OPENAI_TEMPERATURE=0.0
-
-# Optional: Custom OpenAI endpoint (for local models, etc.)
-# OPENAI_BASE_URL=http://localhost:11434/v1
-```
-
-### Required Environment Variables
-
-| Variable | Description |
-|----------|-------------|
-| `ARP_URL` | Base URL of the ARP server |
-| `ARP_USERNAME` | Your ARP login email |
-| `ARP_PASSWORD` | Your ARP password |
-| `OPENAI_API_KEY` | OpenAI API key for LLM |
-
-### Optional Environment Variables
-
-| Variable | Description | Default |
-|----------|-------------|---------|
-| `OPENAI_MODEL` | Model to use | `gpt-4` |
-| `OPENAI_TEMPERATURE` | Sampling temperature | `0.0` |
-| `OPENAI_BASE_URL` | Custom OpenAI-compatible endpoint | OpenAI API |
-
----
-
-## Running the Agent
-
-```bash
-python run_arp_agent.py
-```
-
-### Expected Output
-
-```
-Testing connectivity to OpenAI API (api.openai.com)...
-✓ Successfully connected to OpenAI API (api.openai.com)
-Connecting to ARP server at http://localhost:8080...
-Successfully authenticated with ARP server
-Connecting to MCP server...
-Discovered 3 MCP tools: ['introspect', 'query', 'mutate']
-Initializing LLM agent...
-Agent initialized successfully.
-
-Subscribing to ARP resources...
-Available resources: ['taskCreated', 'taskUpdated', 'taskDeleted', 'messageAdded']
-  Subscribed to: graphql://subscription/taskCreated
-  Subscribed to: graphql://subscription/taskUpdated
-  Subscribed to: graphql://subscription/taskDeleted
-  Subscribed to: graphql://subscription/messageAdded
-
-Listening for events. Press Ctrl+C to stop.
-```
-
----
-
-## How It Works
-
-### Architecture
-
-```
-┌─────────────────┐      GraphQL Login      ┌─────────────────┐
-│   ARP Agent     │ ──────────────────────► │   ARP Server    │
-│                 │ ◄────────────────────── │                 │
-│  ┌───────────┐  │      JWT Token          │                 │
-│  │    LLM    │  │                         │  ┌───────────┐  │
-│  └───────────┘  │      SSE Connect        │  │    MCP    │  │
-│        │        │ ──────────────────────► │  │  Server   │  │
-│        ▼        │      /mcp endpoint      │  └───────────┘  │
-│  ┌───────────┐  │                         │        │        │
-│  │MCP Client │◄─┼──── Tool Discovery      │        │        │
-│  └───────────┘  │      Tool Calls         │        ▼        │
-│        │        │      Notifications      │  ┌───────────┐  │
-│        ▼        │                         │  │ GraphQL   │  │
-│  ┌───────────┐  │                         │  │  Engine   │  │
-│  │  Events   │◄─┼─────────────────────────┼──┤           │  │
-│  └───────────┘  │      Real-time          │  └───────────┘  │
-└─────────────────┘      Subscriptions      └─────────────────┘
-```
-
-### Event Processing Flow
-
-1. **Event Received**: Task or message event via MCP resource notification
-2. **Context Built**: Extract relevant details (ID, title, content, sender, etc.)
-3. **LLM Invoked**: Agent processes the event with available tools
-4. **Tool Execution**: LLM may call MCP tools to query/mutate data
-5. **Response Generated**: Agent produces a result or takes action
-
----
-
-## MCP Communication
-
-The agent uses the **Model Context Protocol (MCP)** to communicate with the ARP server:
-
-### Connection Flow
-
-1. **SSE Connection**: Connect to `/mcp` endpoint via Server-Sent Events
-2. **Endpoint Discovery**: Receive message endpoint URL from `endpoint` event
-3. **Initialize**: Send `initialize` request with protocol version and client info
-4. **Tool Discovery**: Call `tools/list` to discover available tools
-5. **Subscribe**: Call `resources/subscribe` for real-time event streams
-
-### Authentication
-
-Authentication is handled via JWT tokens:
-
-1. Login via GraphQL `login` mutation with email/password
-2. Receive JWT token in response
-3. Include token in SSE connection headers (`Authorization: Bearer <token>`)
-4. Token is automatically propagated to all MCP requests
-
-### Protocol Details
-
-- **Protocol Version**: `2024-11-05`
-- **Transport**: HTTP POST for requests, SSE for responses/notifications
-- **Format**: JSON-RPC 2.0
-
----
-
-## Available MCP Tools
-
-The ARP MCP server exposes three tools:
-
-### 1. `introspect`
-
-Discover the GraphQL schema - types, fields, queries, mutations.
-
-```python
-# Get full schema
-result = mcp_client.call_tool("introspect", {})
-
-# Get specific type
-result = mcp_client.call_tool("introspect", {"typeName": "User"})
-```
-
-### 2. `query`
-
-Execute GraphQL queries (read operations).
-
-```python
-# Query users
-result = mcp_client.call_tool("query", {
-    "query": "{ users { id email roles { name } } }"
-})
-
-# Query with variables
-result = mcp_client.call_tool("query", {
-    "query": "query User($id: ID!) { user(id: $id) { id email } }",
-    "variables": {"id": "1"}
-})
-```
-
-### 3. `mutate`
-
-Execute GraphQL mutations (create/update/delete operations).
-
-```python
-# Create a task
-result = mcp_client.call_tool("mutate", {
-    "mutation": """
-        mutation CreateTask($input: NewTask!) {
-            createTask(input: $input) { id title }
-        }
-    """,
-    "variables": {
-        "input": {
-            "title": "New Task",
-            "content": "Task description",
-            "createdById": "1"
-        }
-    }
-})
-
-# Delete a note
-result = mcp_client.call_tool("mutate", {
-    "mutation": "mutation DeleteNote($id: ID!) { deleteNote(id: $id) }",
-    "variables": {"id": "123"}
-})
-```
-
----
-
-## Workflows
-
-The ARP platform supports **workflows** - configurable, DAG-based process automation that coordinates tasks across agents and users. Workflows enable you to define multi-step processes with dependencies, parallel execution, and automatic task creation.
-
-### Workflow Concepts
-
-| Concept | Description |
-|---------|-------------|
-| **WorkflowTemplate** | Admin-defined workflow definition (JSON DAG structure) |
-| **WorkflowInstance** | A running instance of a workflow template |
-| **WorkflowNode** | A single step in a workflow instance (maps to a Task) |
-| **WorkflowEdge** | Dependency relationship between nodes |
-
-### Node Types
-
-| Type | Description |
-|------|-------------|
-| `task` | Creates and assigns a task to a user |
-| `condition` | Conditional branching based on workflow context |
-| `parallel` | Fork into multiple concurrent branches |
-| `join` | Wait for multiple branches to complete |
-| `trigger` | External event trigger (webhook, schedule, etc.) |
-
-### Node Status Lifecycle
-
-```
-pending → ready → running → completed
-                  └── failed → (retry or abort)
-                  └── skipped
-```
-
-- **pending**: Waiting for dependencies to complete
-- **ready**: All dependencies satisfied, ready to execute
-- **running**: Currently executing (task created)
-- **completed**: Successfully finished
-- **failed**: Execution failed (may retry)
-- **skipped**: Conditionally bypassed
-
-### Workflow Definition Format
-
-Workflows are defined as JSON DAGs (Directed Acyclic Graphs):
-
-```json
-{
-  "nodes": {
-    "start": {
-      "type": "task",
-      "title": "Initial Review",
-      "content": "Review the submitted request",
-      "assignee": "reviewer@example.com",
-      "dependsOn": []
-    },
-    "parallel_analysis": {
-      "type": "parallel",
-      "title": "Parallel Analysis",
-      "content": "Run multiple analyses in parallel",
-      "dependsOn": ["start"]
-    },
-    "technical_review": {
-      "type": "task",
-      "title": "Technical Review",
-      "content": "Review technical aspects",
-      "assignee": "tech@example.com",
-      "dependsOn": ["parallel_analysis"]
-    },
-    "business_review": {
-      "type": "task",
-      "title": "Business Review",
-      "content": "Review business impact",
-      "assignee": "business@example.com",
-      "dependsOn": ["parallel_analysis"]
-    },
-    "join_reviews": {
-      "type": "join",
-      "title": "Join Reviews",
-      "content": "Wait for all reviews to complete",
-      "dependsOn": ["technical_review", "business_review"]
-    },
-    "final_approval": {
-      "type": "task",
-      "title": "Final Approval",
-      "content": "Make final decision",
-      "assignee": "approver@example.com",
-      "dependsOn": ["join_reviews"]
-    }
-  }
-}
-```
-
-### Creating Workflows via MCP
-
-Use the `mutate` tool to create workflow templates and start instances:
-
-```python
-# Create a workflow template
-result = mcp_client.call_tool("mutate", {
-    "mutation": """
-        mutation CreateWorkflowTemplate($input: NewWorkflowTemplate!) {
-            createWorkflowTemplate(input: $input) {
-                id
-                name
-                isActive
-            }
-        }
-    """,
-    "variables": {
-        "input": {
-            "name": "Approval Process",
-            "description": "Multi-step approval workflow",
-            "definition": '{"nodes": {...}}',
-            "isActive": True
-        }
-    }
-})
-
-# Start a workflow instance
-result = mcp_client.call_tool("mutate", {
-    "mutation": """
-        mutation StartWorkflow($templateId: ID!, $input: StartWorkflowInput!) {
-            startWorkflow(templateId: $templateId, input: $input) {
-                id
-                status
-                createdAt
-            }
-        }
-    """,
-    "variables": {
-        "templateId": "1",
-        "input": {
-            "serviceId": "5",
-            "context": '{"requestId": "REQ-123"}'
-        }
-    }
-})
-```
-
-### Querying Workflow State
-
-```python
-# List all workflow templates
-result = mcp_client.call_tool("query", {
-    "query": """
-        {
-            workflowTemplates {
-                id
-                name
-                description
-                isActive
-            }
-        }
-    """
-})
-
-# Get workflow instance status
-result = mcp_client.call_tool("query", {
-    "query": """
-        query WorkflowInstance($id: ID!) {
-            workflowInstance(id: $id) {
-                id
-                status
-                context
-                service { id name }
-                template { name }
-            }
-        }
-    """,
-    "variables": {"id": "1"}
-})
-```
-
-### Workflow Execution Flow
-
-1. **Template Created**: Admin defines workflow structure
-2. **Instance Started**: Workflow instance created from template
-3. **Root Nodes Execute**: Nodes with no dependencies create tasks
-4. **Dependencies Resolve**: As tasks complete, downstream nodes become ready
-5. **Parallel Branches**: Multiple branches execute concurrently
-6. **Join Points**: Wait for all incoming branches to complete
-7. **Completion**: Workflow marked complete when all nodes finish
-
-### Agent Interaction with Workflows
-
-Agents can interact with workflows through MCP tools:
-
-- **Query** workflow templates and instances to understand current state
-- **Create** workflow templates for new processes
-- **Start** workflow instances when triggered by events
-- **Update** task status to progress workflow nodes
-- **Monitor** workflow completion and handle failures
-
-Example agent workflow handling:
-
-```python
-# Agent receives task completion event
-# Check if task is part of a workflow
-result = mcp_client.call_tool("query", {
-    "query": """
-        query TaskWorkflow($taskId: ID!) {
-            task(id: $taskId) {
-                id
-                title
-                workflowNodes {
-                    id
-                    workflowInstance {
-                        id
-                        status
-                        template { name }
-                    }
-                }
-            }
-        }
-    """,
-    "variables": {"taskId": task_id}
-})
-
-# If task is part of workflow, check downstream nodes
-# Agent can proactively notify next assignees or take actions
-```
-
----
-
-## Programmatic Usage
-
-### Using MCPClient Directly
-
-```python
-from llm_agents.mcp_client import login_and_create_mcp_client
-
-# Login and create client
-client = login_and_create_mcp_client(
-    url="http://localhost:8080",
-    username="admin@example.com",
-    password="secret123"
-)
-
-# Connect to MCP server
-client.connect()
-client.initialize()
-
-# Discover tools
-tools = client.list_tools()
-print(f"Available tools: {[t.name for t in tools]}")
-
-# Call tools
-users = client.call_tool("query", {"query": "{ users { id email } }"})
-print(users)
-
-# Subscribe to resources
-client.subscribe_resource("graphql://subscription/taskCreated")
-
-# Listen for notifications
-def on_notification(data):
-    print(f"Received: {data}")
-
-client.listen_for_notifications(on_notification)
-
-# Cleanup
-client.close()
-```
-
-### Using the Agent with MCP
-
-```python
-from llm_agents import Agent, ChatLLM
-from llm_agents.mcp_client import login_and_create_mcp_client
-
-# Create authenticated MCP client
-mcp_client = login_and_create_mcp_client(
-    url="http://localhost:8080",
-    username="admin@example.com",
-    password="secret123"
-)
-
-# Connect and initialize
-mcp_client.connect()
-mcp_client.initialize()
-mcp_client.list_tools()
-
-# Create agent with MCP client
-llm = ChatLLM()  # Uses OPENAI_API_KEY from environment
-agent = Agent(llm=llm, mcp_client=mcp_client)
-
-# Run the agent
-result = agent.run("List all users and their roles")
-print(result)
-
-# Cleanup
-mcp_client.close()
-```
-
----
-
-## MCP Resources (Subscriptions)
-
-The ARP MCP server exposes resources for real-time GraphQL subscriptions:
-
-| Resource URI | Description |
-|--------------|-------------|
-| `graphql://subscription/taskCreated` | New task events (received by assignee) |
-| `graphql://subscription/taskUpdated` | Task update events (received by assignee) |
-| `graphql://subscription/taskDeleted` | Task deletion events (received by assignee) |
-| `graphql://subscription/messageAdded` | New message events (received by receivers) |
-
-### Subscribing to Resources
-
-```python
-# List available resources
-resources = mcp_client.list_resources()
-for resource in resources:
-    print(f"{resource['uri']}: {resource['name']}")
-
-# Subscribe to task events
-mcp_client.subscribe_resource("graphql://subscription/taskCreated")
-mcp_client.subscribe_resource("graphql://subscription/taskUpdated")
-
-# Unsubscribe
-mcp_client.unsubscribe_resource("graphql://subscription/taskCreated")
-```
-
----
-
-## Event Filtering
-
-Events are filtered by the ARP server based on user context:
-
-- **Task Events**: Only received for tasks where the user is the **assignee**
-- **Message Events**: Only received for messages where the user is a **receiver**
-
-This ensures each user only receives relevant notifications.
-
----
-
-## Testing
-
-Run the integration tests (requires a running ARP server):
-
-```bash
-python -m pytest tests/integration/ -v --no-cov
-```
-
-### Test Categories
-
-- **Login Tests**: Authentication flow
-- **Connection Tests**: MCP connection and initialization
-- **Tool Tests**: Introspect, query, and mutate operations
-- **Resource Tests**: Subscription functionality
-- **Error Handling Tests**: Error responses and edge cases
-
----
-
-## Project Structure
-
-```
-arp_agent/
-├── llm_agents/
-│   ├── __init__.py          # Package exports
-│   ├── agent.py             # Agent with tool-calling
-│   ├── llm.py               # OpenAI LLM wrapper
-│   └── mcp_client.py        # MCP client implementation
-├── tests/
-│   ├── conftest.py          # Pytest fixtures
-│   ├── test_setup_validation.py
-│   ├── unit/                # Unit tests
-│   └── integration/         # Integration tests
-│       └── test_arp_agent_integration.py
-├── specs/
-│   └── schema.graphqls      # GraphQL schema reference
-├── run_arp_agent.py         # Main entry point
-├── run_tests.py             # Test runner wrapper
-├── pyproject.toml           # Poetry configuration
-├── poetry.lock              # Locked dependencies
-├── requirements.txt         # Python dependencies (for pip)
-├── .env.example             # Environment template
-├── CLIENT_GUIDE.md          # ARP client implementation guide
-└── README.md                # This file
-```
-
----
-
-## License
-
-See [LICENSE](LICENSE) for details.

+ 389 - 0
arp_agent/README.md

@@ -0,0 +1,389 @@
+# ARP Agent
+
+An LLM-powered agent that processes events from the ARP (Agent-native ERP) platform using the Model Context Protocol (MCP).
+
+## Overview
+
+The ARP Agent is an intelligent agent that:
+- Connects to an ARP server via MCP over Server-Sent Events (SSE)
+- Uses an OpenAI-compatible LLM for processing events
+- Subscribes to ARP resources (taskCreated, taskUpdated, taskDeleted, messageAdded)
+- Takes actions on the platform using GraphQL tools (introspect, query, mutate)
+- Supports external MCP servers for additional capabilities
+
+## Prerequisites
+
+- Go 1.25 or later
+- Access to an ARP server
+- An OpenAI-compatible LLM endpoint (OpenAI, local LLM, vLLM, etc.)
+
+## Quick Start
+
+1. **Clone and navigate to the agent:**
+   ```bash
+   cd arp_agent
+   ```
+
+2. **Copy the example environment file:**
+   ```bash
+   cp .env.example .env
+   ```
+
+3. **Edit `.env` with your configuration** (see Configuration section below)
+
+4. **Build and run:**
+   ```bash
+   go build -o arp_agent
+   ./arp_agent
+   ```
+
+## Configuration
+
+### Where to Put Config Files
+
+The agent looks for configuration files in the following locations (in order of priority):
+
+1. **Current working directory** - Where you run the agent from
+2. **Same directory as the executable** - For production deployments
+3. **Absolute paths** - If specified in environment variables
+
+### Required: `.env` File
+
+Copy `.env.example` to `.env` and configure:
+
+```bash
+# ARP Server Configuration (required)
+ARP_URL=http://localhost:8080
+ARP_USERNAME=mira@slang.com
+ARP_PASSWORD=mira
+
+# OpenAI API Configuration (required)
+OPENAI_API_KEY=your-api-key-here
+OPENAI_MODEL=openai/gpt-4
+OPENAI_TEMPERATURE=0.5
+OPENAI_MAX_TOKENS=4096
+
+# OpenAI Base URL (optional - for local/custom LLMs)
+# Default: https://api.openai.com/v1
+OPENAI_BASE_URL=http://localhost:1234/v1
+```
+
+### Optional: `mcp.json` File
+
+For external MCP servers, create a `mcp.json` file (copy from `mcp.json.example`):
+
+```json
+{
+  "mcpServers": {
+    "brave-search": {
+      "command": "npx",
+      "args": ["-y", "@modelcontextprotocol/server-brave-search"],
+      "env": {
+        "BRAVE_API_KEY": "your-api-key-here"
+      }
+    }
+  }
+}
+```
+
+To use the MCP config file, set the environment variable:
+```bash
+MCP_CONFIG_PATH=/path/to/mcp.json
+```
+
+### Environment Variables Reference
+
+| Variable | Required | Default | Description |
+|----------|----------|---------|-------------|
+| `ARP_URL` | Yes | - | ARP server URL (e.g., `http://localhost:8080`) |
+| `ARP_USERNAME` | Yes | - | ARP login email |
+| `ARP_PASSWORD` | Yes | - | ARP login password |
+| `OPENAI_API_KEY` | Yes | - | OpenAI API key |
+| `OPENAI_MODEL` | No | `gpt-4` | LLM model name |
+| `OPENAI_TEMPERATURE` | No | `0.0` | LLM temperature (0.0-2.0) |
+| `OPENAI_MAX_TOKENS` | No | `4096` | Max tokens for response |
+| `OPENAI_BASE_URL` | No | OpenAI API | Base URL for LLM (supports local/custom endpoints) |
+| `ARP_AGENT_NAME` | No | `AI Assistant` | Agent's display name |
+| `ARP_AGENT_SPECIALIZATION` | No | `general assistance` | Agent's specialization |
+| `ARP_AGENT_VALUES` | No | `helpfulness, accuracy, and collaboration` | Agent's values |
+| `ARP_AGENT_GOALS` | No | `help teammates accomplish their goals` | Agent's goals |
+| `ARP_MAX_QUEUE_SIZE` | No | `100` | Maximum events to queue |
+| `ARP_MAX_ITERATIONS` | No | `10` | Max tool call iterations per event |
+| `MCP_CONFIG_PATH` | No | - | Path to `mcp.json` |
+
+## Running the Agent
+
+### Basic Usage
+
+```bash
+# Build
+go build -o arp_agent
+
+# Run
+./arp_agent
+```
+
+### With Custom Config Path
+
+```bash
+# Use custom .env location
+cd /custom/path && ./arp_agent
+
+# Or use absolute path for MCP config
+MCP_CONFIG_PATH=/etc/arp/mcp.json ./arp_agent
+```
+
+### Docker (Example)
+
+```dockerfile
+FROM golang:1.25-alpine AS builder
+WORKDIR /app
+COPY . .
+RUN go build -o arp_agent
+
+FROM alpine:latest
+COPY --from=builder /app/arp_agent .
+COPY --from=builder /app/.env.example .env
+RUN apk add --no-cache ca-certificates
+ENTRYPOINT ["./arp_agent"]
+```
+
+## Agent Identity
+
+The agent has a customizable identity that defines how it behaves on the platform:
+
+```bash
+# Example: HR Specialist
+ARP_AGENT_NAME=HR Bot
+ARP_AGENT_SPECIALIZATION=HR and people operations
+ARP_AGENT_VALUES=empathy, privacy, and professionalism
+ARP_AGENT_GOALS=help employees with HR questions and processes
+
+# Example: Engineering Assistant
+ARP_AGENT_NAME=Dev Assistant
+ARP_AGENT_SPECIALIZATION=software development and DevOps
+ARP_AGENT_VALUES=code quality, documentation, and continuous improvement
+ARP_AGENT_GOALS=help developers ship reliable software efficiently
+```
+
+**Important:** The agent IS a user on the platform (not an assistant helping a user). When it creates tasks, notes, or messages, it does so as itself.
+
+## Available Tools
+
+The agent has access to these ARP tools via MCP:
+
+### `introspect`
+Discover the GraphQL schema structure.
+
+```graphql
+# Get full schema
+introspect()
+
+# Get specific type
+introspect(typeName: "Task")
+
+# Get mutation fields
+introspect(typeName: "Mutation")
+```
+
+### `query`
+Execute GraphQL queries (read operations).
+
+```graphql
+query {
+  tasks(status: "open") {
+    id
+    title
+    assignee {
+      email
+    }
+  }
+}
+```
+
+### `mutate`
+Execute GraphQL mutations (create/update/delete operations).
+
+```graphql
+# Create a task
+mutation {
+  createTask(input: {
+    title: "Review PR"
+    content: "Please review PR #123"
+    priority: "high"
+    serviceId: "service-123"
+  }) {
+    id
+    title
+  }
+}
+
+# Update a task
+mutation {
+  updateTask(id: "task-123", input: {
+    statusId: "status-done"
+  }) {
+    id
+    status {
+      label
+    }
+  }
+}
+```
+
+## External MCP Servers
+
+Add external MCP servers to extend the agent's capabilities:
+
+### Brave Search
+
+```json
+{
+  "mcpServers": {
+    "brave-search": {
+      "command": "npx",
+      "args": ["-y", "@modelcontextprotocol/server-brave-search"],
+      "env": {
+        "BRAVE_API_KEY": "your-api-key"
+      }
+    }
+  }
+}
+```
+
+### GitHub
+
+```json
+{
+  "mcpServers": {
+    "github": {
+      "command": "npx",
+      "args": ["-y", "@modelcontextprotocol/server-github"],
+      "env": {
+        "GITHUB_TOKEN": "your-github-token"
+      }
+    }
+  }
+}
+```
+
+### Filesystem
+
+```json
+{
+  "mcpServers": {
+    "filesystem": {
+      "command": "npx",
+      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/directory"]
+    }
+  }
+}
+```
+
+External tools are prefixed with the server name (e.g., `brave-search.search`, `github.list_pull_requests`).
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                        ARP Agent                             │
+├─────────────────────────────────────────────────────────────┤
+│  ┌─────────────┐    ┌─────────────┐    ┌───────────────┐  │
+│  │   LLM       │    │   Agent     │    │  MCP Manager  │  │
+│  │ (OpenAI)    │◄───│  Processor  │───►│               │  │
+│  └─────────────┘    └─────────────┘    └───────┬───────┘  │
+│                                                  │          │
+│  ┌──────────────────────────────────────────────┼────────┐ │
+│  │                    Event Queues              │        │ │
+│  │  ┌──────────────┐    ┌──────────────────┐  │        │ │
+│  │  │ Task Events  │    │ Message Events   │  │        │ │
+│  │  │ (taskCreated │    │ (messageAdded)   │  │        │ │
+│  │  │  taskUpdated │    │                  │  │        │ │
+│  │  │  taskDeleted)│    │                  │  │        │ │
+│  │  └──────────────┘    └──────────────────┘  │        │ │
+│  └─────────────────────────────────────────────┴────────┘ │
+└─────────────────────────────────────────────────────────────┘
+                              │
+                              ▼
+┌─────────────────────────────────────────────────────────────┐
+│                      External MCP Servers                    │
+├─────────────────────────────────────────────────────────────┤
+│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐     │
+│  │ brave-search │  │   github     │  │ filesystem   │     │
+│  └──────────────┘  └──────────────┘  └──────────────┘     │
+└─────────────────────────────────────────────────────────────┘
+                              │
+                              ▼
+┌─────────────────────────────────────────────────────────────┐
+│                        ARP Server                            │
+├─────────────────────────────────────────────────────────────┤
+│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐     │
+│  │   GraphQL    │  │     MCP      │  │   Database   │     │
+│  │   (query)    │  │   (SSE)      │  │              │     │
+│  └──────────────┘  └──────────────┘  └──────────────┘     │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Components
+
+- **LLM**: OpenAI-compatible language model for processing events
+- **Agent Processor**: Processes events with the LLM, handles tool calls
+- **MCP Manager**: Aggregates tools from ARP server and external MCP servers
+- **Event Queues**: Non-blocking queues for task and message events
+- **MCP Client**: SSE-based connection to ARP server
+- **MCP Stdio Client**: stdio-based connection to external MCP servers
+
+## Troubleshooting
+
+### Connection Issues
+
+**"Failed to connect to OpenAI API"**
+- Verify `OPENAI_API_KEY` is correct
+- Check `OPENAI_BASE_URL` if using a custom endpoint
+- Ensure network connectivity to the LLM provider
+
+**"Failed to login"**
+- Verify `ARP_URL`, `ARP_USERNAME`, and `ARP_PASSWORD` are correct
+- Check that the ARP server is running and accessible
+
+**"Failed to connect to SSE"**
+- Ensure the ARP server has the MCP endpoint enabled
+- Check network/firewall settings
+
+### Configuration Issues
+
+**"ARP_URL environment variable is required"**
+- Copy `.env.example` to `.env` and fill in the values
+- Ensure the file is in the correct location
+
+**"OPENAI_API_KEY environment variable is required"**
+- Add your OpenAI API key to the `.env` file
+
+### MCP Server Issues
+
+**External MCP server fails to start**
+- Check that Node.js and npx are installed
+- Verify the server command and arguments in `mcp.json`
+- Check that required API keys are set in the `env` section
+
+**"Unknown tool" errors**
+- External tools are prefixed with the server name (e.g., `brave-search.search`)
+- Check the tool name matches what's available in `mcp.json`
+
+### Performance Issues
+
+**"Max iterations reached"**
+- Increase `ARP_MAX_ITERATIONS` in `.env` (default: 10)
+- Simplify the task or break it into smaller steps
+
+**"Queue is full, dropping event"**
+- Increase `ARP_MAX_QUEUE_SIZE` in `.env` (default: 100)
+- Check for a backlog of events
+
+**"Response truncated: model hit token limit"**
+- Increase `OPENAI_MAX_TOKENS` in `.env` (default: 4096)
+- Consider using a model with higher token limits
+
+## License
+
+MIT

+ 9 - 9
arp_agent/agent.go

@@ -69,9 +69,9 @@ func (q *EventQueue) Len() int {
 
 // Agent is an LLM-powered agent that processes events using MCP tools
 type Agent struct {
-	llm       *LLM
-	mcpClient *MCPClient
-	tools     []openai.Tool
+	llm        *LLM
+	mcpManager *MCPManager
+	tools      []openai.Tool
 	// Agent identity configuration
 	agentName      string
 	username       string // ARP login email - the agent IS this user on the platform
@@ -90,10 +90,10 @@ type Agent struct {
 }
 
 // NewAgent creates a new Agent with the given configuration
-func NewAgent(llm *LLM, mcpClient *MCPClient, cfg *Config) *Agent {
+func NewAgent(llm *LLM, mcpManager *MCPManager, cfg *Config) *Agent {
 	agent := &Agent{
-		llm:       llm,
-		mcpClient: mcpClient,
+		llm:        llm,
+		mcpManager: mcpManager,
 	}
 
 	// Load identity from config (which already has defaults)
@@ -118,7 +118,7 @@ func NewAgent(llm *LLM, mcpClient *MCPClient, cfg *Config) *Agent {
 
 // Initialize initializes the agent by discovering tools
 func (a *Agent) Initialize() error {
-	mcpTools, err := a.mcpClient.ListTools()
+	mcpTools, err := a.mcpManager.ListTools()
 	if err != nil {
 		return fmt.Errorf("failed to list tools: %w", err)
 	}
@@ -343,7 +343,7 @@ func (a *Agent) processWithTools(ctx context.Context, messages []openai.ChatComp
 			log.Printf("Calling tool: %s with args: %v", name, args)
 
 			// Execute tool via MCP
-			result, err := a.mcpClient.CallTool(name, args)
+			result, err := a.mcpManager.CallTool(name, args)
 			if err != nil {
 				log.Printf("Tool call failed: %v", err)
 				messages = append(messages, openai.ChatCompletionMessage{
@@ -434,7 +434,7 @@ func (a *Agent) Run(ctx context.Context, userMessage string) (string, error) {
 				continue
 			}
 
-			result, err := a.mcpClient.CallTool(name, args)
+			result, err := a.mcpManager.CallTool(name, args)
 			if err != nil {
 				messages = append(messages, openai.ChatCompletionMessage{
 					Role:       openai.ChatMessageRoleTool,

BIN
arp_agent/arp_agent


+ 6 - 0
arp_agent/config.go

@@ -31,6 +31,9 @@ type Config struct {
 
 	// Agent Iteration Configuration
 	MaxIterations int
+
+	// MCP Configuration
+	MCPConfigPath string
 }
 
 // LoadConfig loads configuration from environment variables
@@ -74,6 +77,9 @@ func LoadConfig() (*Config, error) {
 	// Agent Iteration Configuration with defaults
 	cfg.MaxIterations = getEnvIntWithDefault("ARP_MAX_ITERATIONS", 10)
 
+	// MCP Configuration with default
+	cfg.MCPConfigPath = getEnvWithDefault("MCP_CONFIG_PATH", "")
+
 	return cfg, nil
 }
 

+ 35 - 5
arp_agent/main.go

@@ -50,18 +50,45 @@ func main() {
 	if err := mcpClient.Connect(); err != nil {
 		log.Fatalf("Failed to connect to MCP: %v", err)
 	}
-	defer mcpClient.Close()
 
 	// Initialize MCP
 	initResult, err := mcpClient.Initialize()
 	if err != nil {
+		mcpClient.Close()
 		log.Fatalf("Failed to initialize MCP: %v", err)
 	}
 	log.Printf("Connected to MCP server: %s v%s", initResult.ServerInfo.Name, initResult.ServerInfo.Version)
 
+	// Create MCP manager to aggregate ARP + external servers
+	mcpManager := NewMCPManager(mcpClient)
+
+	// Load external MCP servers from config file if specified
+	if cfg.MCPConfigPath != "" {
+		log.Printf("Loading MCP server configuration from %s", cfg.MCPConfigPath)
+		mcpConfig, err := LoadMCPConfig(cfg.MCPConfigPath)
+		if err != nil {
+			mcpManager.Close()
+			log.Fatalf("Failed to load MCP config: %v", err)
+		}
+
+		for name, serverConfig := range mcpConfig.MCPServers {
+			if err := mcpManager.AddExternalServer(name, serverConfig); err != nil {
+				log.Printf("Warning: Failed to add external MCP server '%s': %v", name, err)
+			}
+		}
+	}
+
+	// Initialize the manager (discovers ARP tools)
+	if err := mcpManager.Initialize(); err != nil {
+		mcpManager.Close()
+		log.Fatalf("Failed to initialize MCP manager: %v", err)
+	}
+	log.Printf("MCP manager initialized with %d tools from %d servers", mcpManager.GetToolCount(), len(mcpManager.GetServerNames()))
+
 	// Create and initialize agent
-	agent := NewAgent(llm, mcpClient, cfg)
+	agent := NewAgent(llm, mcpManager, cfg)
 	if err := agent.Initialize(); err != nil {
+		mcpManager.Close()
 		log.Fatalf("Failed to initialize agent: %v", err)
 	}
 	log.Printf("Agent initialized successfully.")
@@ -72,15 +99,16 @@ func main() {
 
 	// List and subscribe to resources
 	log.Printf("Subscribing to ARP resources...")
-	resources, err := mcpClient.ListResources()
+	resources, err := mcpManager.ListResources()
 	if err != nil {
+		mcpManager.Close()
 		log.Fatalf("Failed to list resources: %v", err)
 	}
 
 	log.Printf("Available resources: %v", resourceURIs(resources))
 	for _, resource := range resources {
 		log.Printf("  Subscribed to: %s", resource.URI)
-		if err := mcpClient.SubscribeResource(resource.URI); err != nil {
+		if err := mcpManager.SubscribeResource(resource.URI); err != nil {
 			log.Printf("Warning: Failed to subscribe to %s: %v", resource.URI, err)
 		}
 	}
@@ -108,10 +136,12 @@ func main() {
 	for {
 		select {
 		case <-ctx.Done():
+			mcpManager.Close()
 			return
-		case event, ok := <-mcpClient.Notifications():
+		case event, ok := <-mcpManager.Notifications():
 			if !ok {
 				log.Println("Notification channel closed")
+				mcpManager.Close()
 				return
 			}
 			handleNotification(agent, event)

+ 32 - 0
arp_agent/mcp.json.example

@@ -0,0 +1,32 @@
+{
+  "mcpServers": {
+    "brave-search": {
+      "command": "npx",
+      "args": [
+        "-y",
+        "@modelcontextprotocol/server-brave-search"
+      ],
+      "env": {
+        "BRAVE_API_KEY": "your-api-key-here"
+      }
+    },
+    "filesystem": {
+      "command": "npx",
+      "args": [
+        "-y",
+        "@modelcontextprotocol/server-filesystem",
+        "/path/to/allowed/directory"
+      ]
+    },
+    "github": {
+      "command": "npx",
+      "args": [
+        "-y",
+        "@modelcontextprotocol/server-github"
+      ],
+      "env": {
+        "GITHUB_TOKEN": "your-github-token-here"
+      }
+    }
+  }
+}

+ 72 - 0
arp_agent/mcp_config.go

@@ -0,0 +1,72 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+// MCPServerConfig represents the configuration for a single MCP server
+type MCPServerConfig struct {
+	Command string            `json:"command"`
+	Args    []string          `json:"args"`
+	Env     map[string]string `json:"env,omitempty"`
+}
+
+// MCPConfig represents the root configuration for MCP servers
+type MCPConfig struct {
+	MCPServers map[string]MCPServerConfig `json:"mcpServers"`
+}
+
+// LoadMCPConfig loads the MCP configuration from a JSON file
+func LoadMCPConfig(path string) (*MCPConfig, error) {
+	// If no path provided, return empty config
+	if path == "" {
+		return &MCPConfig{MCPServers: make(map[string]MCPServerConfig)}, nil
+	}
+
+	// Check if file exists
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		// File doesn't exist, return empty config
+		return &MCPConfig{MCPServers: make(map[string]MCPServerConfig)}, nil
+	}
+
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read MCP config file: %w", err)
+	}
+
+	var config MCPConfig
+	if err := json.Unmarshal(data, &config); err != nil {
+		return nil, fmt.Errorf("failed to parse MCP config file: %w", err)
+	}
+
+	if config.MCPServers == nil {
+		config.MCPServers = make(map[string]MCPServerConfig)
+	}
+
+	return &config, nil
+}
+
+// DefaultMCPConfigPath returns the default path for mcp.json
+// It looks in the same directory as the .env file
+func DefaultMCPConfigPath() string {
+	// Check for mcp.json in current directory first
+	if _, err := os.Stat("mcp.json"); err == nil {
+		return "mcp.json"
+	}
+
+	// Check for mcp.json in the same directory as the executable
+	execPath, err := os.Executable()
+	if err == nil {
+		execDir := filepath.Dir(execPath)
+		mcpPath := filepath.Join(execDir, "mcp.json")
+		if _, err := os.Stat(mcpPath); err == nil {
+			return mcpPath
+		}
+	}
+
+	// Default to mcp.json in current directory
+	return "mcp.json"
+}

+ 224 - 0
arp_agent/mcp_manager.go

@@ -0,0 +1,224 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+	"strings"
+	"sync"
+)
+
+// MCPClientInterface defines the interface for MCP client operations
+type MCPClientInterface interface {
+	ListTools() ([]Tool, error)
+	CallTool(name string, arguments map[string]interface{}) (*CallToolResult, error)
+}
+
+// MCPServerInfo holds information about a connected MCP server
+type MCPServerInfo struct {
+	Name   string
+	Client MCPClientInterface
+	Tools  []Tool
+}
+
+// MCPManager manages multiple MCP clients (ARP + external servers)
+// It aggregates tools from all servers and routes tool calls appropriately
+type MCPManager struct {
+	// Primary ARP client
+	arpClient *MCPClient
+
+	// External MCP servers (stdio-based)
+	externalClients map[string]*MCPStdioClient
+
+	// Aggregated tools with server prefix
+	tools []Tool
+
+	// Map of prefixed tool name -> server name
+	toolToServer map[string]string
+
+	// Map of prefixed tool name -> original tool name
+	toolToOriginal map[string]string
+
+	// Mutex for thread-safe access
+	mu sync.RWMutex
+}
+
+// NewMCPManager creates a new MCP manager
+func NewMCPManager(arpClient *MCPClient) *MCPManager {
+	return &MCPManager{
+		arpClient:       arpClient,
+		externalClients: make(map[string]*MCPStdioClient),
+		tools:           make([]Tool, 0),
+		toolToServer:    make(map[string]string),
+		toolToOriginal:  make(map[string]string),
+	}
+}
+
+// AddExternalServer adds and initializes an external MCP server
+// Returns error if the server fails to start, but does not fail the whole manager
+func (m *MCPManager) AddExternalServer(name string, config MCPServerConfig) error {
+	client := NewMCPStdioClient(name, config)
+
+	// Start the process
+	if err := client.Start(); err != nil {
+		return fmt.Errorf("failed to start MCP server '%s': %w", name, err)
+	}
+
+	// Initialize the server
+	if _, err := client.Initialize(); err != nil {
+		client.Close()
+		return fmt.Errorf("failed to initialize MCP server '%s': %w", name, err)
+	}
+
+	// List tools from the server
+	tools, err := client.ListTools()
+	if err != nil {
+		client.Close()
+		return fmt.Errorf("failed to list tools from MCP server '%s': %w", name, err)
+	}
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	m.externalClients[name] = client
+
+	// Prefix tools with server name
+	for _, tool := range tools {
+		prefixedName := fmt.Sprintf("%s.%s", name, tool.Name)
+		m.tools = append(m.tools, Tool{
+			Name:        prefixedName,
+			Description: tool.Description,
+			InputSchema: tool.InputSchema,
+		})
+		m.toolToServer[prefixedName] = name
+		m.toolToOriginal[prefixedName] = tool.Name
+	}
+
+	log.Printf("Added external MCP server '%s' with %d tools: %v", name, len(tools), toolNames(tools))
+	return nil
+}
+
+// Initialize initializes the ARP client and discovers tools
+func (m *MCPManager) Initialize() error {
+	// Get tools from ARP client
+	arpTools, err := m.arpClient.ListTools()
+	if err != nil {
+		return fmt.Errorf("failed to list ARP tools: %w", err)
+	}
+
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	// Add ARP tools without prefix (they are the primary tools)
+	for _, tool := range arpTools {
+		m.tools = append(m.tools, tool)
+		m.toolToServer[tool.Name] = "arp"
+		m.toolToOriginal[tool.Name] = tool.Name
+	}
+
+	log.Printf("Discovered %d ARP tools: %v", len(arpTools), toolNames(arpTools))
+	return nil
+}
+
+// ListTools returns all aggregated tools from all servers
+func (m *MCPManager) ListTools() ([]Tool, error) {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	// Return a copy to avoid race conditions
+	tools := make([]Tool, len(m.tools))
+	copy(tools, m.tools)
+	return tools, nil
+}
+
+// CallTool routes a tool call to the appropriate server
+func (m *MCPManager) CallTool(name string, arguments map[string]interface{}) (*CallToolResult, error) {
+	m.mu.RLock()
+	serverName, hasServer := m.toolToServer[name]
+	originalName, hasOriginal := m.toolToOriginal[name]
+	m.mu.RUnlock()
+
+	if !hasServer || !hasOriginal {
+		return nil, fmt.Errorf("unknown tool: %s", name)
+	}
+
+	// Route to the appropriate client
+	switch serverName {
+	case "arp":
+		return m.arpClient.CallTool(originalName, arguments)
+	default:
+		// External server
+		client, ok := m.externalClients[serverName]
+		if !ok {
+			return nil, fmt.Errorf("MCP server '%s' not found", serverName)
+		}
+		return client.CallTool(originalName, arguments)
+	}
+}
+
+// Close closes all MCP clients
+func (m *MCPManager) Close() error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	var errors []string
+
+	// Close external clients
+	for name, client := range m.externalClients {
+		if err := client.Close(); err != nil {
+			errors = append(errors, fmt.Sprintf("%s: %v", name, err))
+		}
+	}
+
+	// Close ARP client
+	if m.arpClient != nil {
+		if err := m.arpClient.Close(); err != nil {
+			errors = append(errors, fmt.Sprintf("arp: %v", err))
+		}
+	}
+
+	if len(errors) > 0 {
+		return fmt.Errorf("errors closing MCP clients: %s", strings.Join(errors, ", "))
+	}
+
+	return nil
+}
+
+// GetServerNames returns the names of all connected servers
+func (m *MCPManager) GetServerNames() []string {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+
+	names := []string{"arp"}
+	for name := range m.externalClients {
+		names = append(names, name)
+	}
+	return names
+}
+
+// GetToolCount returns the total number of tools across all servers
+func (m *MCPManager) GetToolCount() int {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	return len(m.tools)
+}
+
+// SubscribeResource subscribes to a resource on the ARP server
+func (m *MCPManager) SubscribeResource(uri string) error {
+	return m.arpClient.SubscribeResource(uri)
+}
+
+// UnsubscribeResource unsubscribes from a resource on the ARP server
+func (m *MCPManager) UnsubscribeResource(uri string) error {
+	return m.arpClient.UnsubscribeResource(uri)
+}
+
+// ListResources lists resources from the ARP server
+func (m *MCPManager) ListResources() ([]Resource, error) {
+	return m.arpClient.ListResources()
+}
+
+// Notifications returns the notification channel from the ARP server
+func (m *MCPManager) Notifications() <-chan json.RawMessage {
+	return m.arpClient.Notifications()
+}

+ 308 - 0
arp_agent/mcp_stdio.go

@@ -0,0 +1,308 @@
+package main
+
+import (
+	"bufio"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"os/exec"
+	"sync"
+	"time"
+)
+
+// MCPStdioClient is an MCP client that communicates via stdin/stdout
+// Used for external MCP servers spawned as child processes
+type MCPStdioClient struct {
+	serverName string
+	config     MCPServerConfig
+	cmd        *exec.Cmd
+	stdin      io.WriteCloser
+	stdout     io.Reader
+	stderr     io.Reader
+
+	// Request ID counter
+	idCounter int
+	idMu      sync.Mutex
+
+	// Pending requests (ID -> response channel)
+	pending   map[interface{}]chan json.RawMessage
+	pendingMu sync.Mutex
+
+	// Tools cache
+	tools []Tool
+
+	// Done channel for cleanup
+	done   chan struct{}
+	doneMu sync.Mutex
+}
+
+// NewMCPStdioClient creates a new stdio MCP client for an external server
+func NewMCPStdioClient(serverName string, config MCPServerConfig) *MCPStdioClient {
+	return &MCPStdioClient{
+		serverName: serverName,
+		config:     config,
+		pending:    make(map[interface{}]chan json.RawMessage),
+		done:       make(chan struct{}),
+	}
+}
+
+// Start spawns the external MCP server process
+func (c *MCPStdioClient) Start() error {
+	// Build command
+	c.cmd = exec.Command(c.config.Command, c.config.Args...)
+
+	// Set environment variables
+	if len(c.config.Env) > 0 {
+		env := os.Environ()
+		for key, value := range c.config.Env {
+			env = append(env, fmt.Sprintf("%s=%s", key, value))
+		}
+		c.cmd.Env = env
+	}
+
+	// Get stdin pipe
+	stdin, err := c.cmd.StdinPipe()
+	if err != nil {
+		return fmt.Errorf("failed to get stdin pipe: %w", err)
+	}
+	c.stdin = stdin
+
+	// Get stdout pipe
+	stdout, err := c.cmd.StdoutPipe()
+	if err != nil {
+		return fmt.Errorf("failed to get stdout pipe: %w", err)
+	}
+	c.stdout = stdout
+
+	// Get stderr pipe for logging
+	stderr, err := c.cmd.StderrPipe()
+	if err != nil {
+		return fmt.Errorf("failed to get stderr pipe: %w", err)
+	}
+	c.stderr = stderr
+
+	// Start the process
+	if err := c.cmd.Start(); err != nil {
+		return fmt.Errorf("failed to start MCP server '%s': %w", c.serverName, err)
+	}
+
+	// Start reading stdout in background
+	go c.readOutput()
+
+	// Start reading stderr in background for logging
+	go c.readStderr()
+
+	return nil
+}
+
+// readOutput reads JSON-RPC responses from stdout
+func (c *MCPStdioClient) readOutput() {
+	scanner := bufio.NewScanner(c.stdout)
+	for scanner.Scan() {
+		line := scanner.Bytes()
+
+		// Parse to check if it's a response (has ID) or notification
+		var msg struct {
+			ID interface{} `json:"id"`
+		}
+		if err := json.Unmarshal(line, &msg); err != nil {
+			log.Printf("[%s] Failed to parse output: %v", c.serverName, err)
+			continue
+		}
+
+		if msg.ID != nil {
+			// JSON numbers are unmarshaled as float64, but we use int for IDs
+			var idKey interface{} = msg.ID
+			if f, ok := msg.ID.(float64); ok {
+				idKey = int(f)
+			}
+			// It's a response - dispatch to pending request
+			c.pendingMu.Lock()
+			if ch, ok := c.pending[idKey]; ok {
+				ch <- json.RawMessage(line)
+				delete(c.pending, idKey)
+			}
+			c.pendingMu.Unlock()
+		}
+		// Notifications are ignored for now (external servers typically don't send them)
+	}
+}
+
+// readStderr reads stderr output for debugging
+func (c *MCPStdioClient) readStderr() {
+	scanner := bufio.NewScanner(c.stderr)
+	for scanner.Scan() {
+		log.Printf("[%s] stderr: %s", c.serverName, scanner.Text())
+	}
+}
+
+// Initialize sends the initialize request
+func (c *MCPStdioClient) Initialize() (*InitializeResult, error) {
+	params := InitializeParams{
+		ProtocolVersion: ProtocolVersion,
+		Capabilities: ClientCapabilities{
+			Roots: &RootsCapability{ListChanged: false},
+		},
+		ClientInfo: ImplementationInfo{
+			Name:    "ARP Agent",
+			Version: "1.0.0",
+		},
+	}
+
+	result := &InitializeResult{}
+	if err := c.sendRequest("initialize", params, result); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+// ListTools discovers available tools from this server
+func (c *MCPStdioClient) ListTools() ([]Tool, error) {
+	result := &ListToolsResult{}
+	if err := c.sendRequest("tools/list", nil, result); err != nil {
+		return nil, err
+	}
+
+	c.tools = result.Tools
+	return result.Tools, nil
+}
+
+// CallTool executes a tool call
+func (c *MCPStdioClient) CallTool(name string, arguments map[string]interface{}) (*CallToolResult, error) {
+	params := CallToolParams{
+		Name:      name,
+		Arguments: arguments,
+	}
+
+	result := &CallToolResult{}
+	if err := c.sendRequest("tools/call", params, result); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
+// GetTools returns the cached tools
+func (c *MCPStdioClient) GetTools() []Tool {
+	return c.tools
+}
+
+// Close stops the external MCP server process
+func (c *MCPStdioClient) Close() error {
+	c.doneMu.Lock()
+	select {
+	case <-c.done:
+		// Already closed
+	default:
+		close(c.done)
+	}
+	c.doneMu.Unlock()
+
+	if c.stdin != nil {
+		c.stdin.Close()
+	}
+
+	if c.cmd != nil && c.cmd.Process != nil {
+		// Give the process a moment to exit gracefully
+		done := make(chan error, 1)
+		go func() {
+			done <- c.cmd.Wait()
+		}()
+
+		select {
+		case <-time.After(5 * time.Second):
+			// Force kill if it doesn't exit gracefully
+			log.Printf("[%s] Force killing process", c.serverName)
+			c.cmd.Process.Kill()
+		case <-done:
+			// Process exited gracefully
+		}
+	}
+
+	return nil
+}
+
+// nextID generates a unique request ID
+func (c *MCPStdioClient) nextID() int {
+	c.idMu.Lock()
+	defer c.idMu.Unlock()
+	c.idCounter++
+	return c.idCounter
+}
+
+// sendRequest sends a JSON-RPC request and waits for the response
+func (c *MCPStdioClient) sendRequest(method string, params interface{}, result interface{}) error {
+	// Build request
+	id := c.nextID()
+	var paramsJSON json.RawMessage
+	if params != nil {
+		var err error
+		paramsJSON, err = json.Marshal(params)
+		if err != nil {
+			return fmt.Errorf("failed to marshal params: %w", err)
+		}
+	}
+
+	req := JSONRPCRequest{
+		JSONRPC: "2.0",
+		ID:      id,
+		Method:  method,
+		Params:  paramsJSON,
+	}
+
+	reqBody, err := json.Marshal(req)
+	if err != nil {
+		return fmt.Errorf("failed to marshal request: %w", err)
+	}
+
+	// Add newline as line delimiter
+	reqBody = append(reqBody, '\n')
+
+	// Register pending request before sending
+	respChan := make(chan json.RawMessage, 1)
+	c.pendingMu.Lock()
+	c.pending[id] = respChan
+	c.pendingMu.Unlock()
+
+	// Cleanup on return
+	defer func() {
+		c.pendingMu.Lock()
+		delete(c.pending, id)
+		c.pendingMu.Unlock()
+	}()
+
+	// Send request
+	if _, err := c.stdin.Write(reqBody); err != nil {
+		return fmt.Errorf("failed to send request: %w", err)
+	}
+
+	// Wait for response
+	select {
+	case respData := <-respChan:
+		// Parse response
+		var rpcResp JSONRPCResponse
+		if err := json.Unmarshal(respData, &rpcResp); err != nil {
+			return fmt.Errorf("failed to parse response: %w", err)
+		}
+
+		if rpcResp.Error != nil {
+			return fmt.Errorf("RPC error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
+		}
+
+		// Parse result if provided
+		if result != nil && rpcResp.Result != nil {
+			if err := json.Unmarshal(rpcResp.Result, result); err != nil {
+				return fmt.Errorf("failed to parse result: %w", err)
+			}
+		}
+
+		return nil
+	case <-time.After(30 * time.Second):
+		return fmt.Errorf("timeout waiting for response from %s", c.serverName)
+	case <-c.done:
+		return fmt.Errorf("client closed")
+	}
+}

+ 0 - 6
arp_agent/testutil_test.go

@@ -35,12 +35,6 @@ type LLMInterface interface {
 	Chat(ctx context.Context, messages []openai.ChatCompletionMessage, tools []openai.Tool) (*openai.ChatCompletionMessage, error)
 }
 
-// MCPClientInterface defines the interface for MCP client operations
-type MCPClientInterface interface {
-	ListTools() ([]Tool, error)
-	CallTool(name string, args map[string]interface{}) (*CallToolResult, error)
-}
-
 // MockLLM is a mock implementation of the LLM for testing
 type MockLLM struct {
 	responses []*openai.ChatCompletionMessage