# Kollabor Engine MCP API Specification

## Overview

This spec defines the MCP (Model Context Protocol) server management endpoints
for kollabor-engine. MCP servers provide external tools that the LLM can call
(filesystem, git, database, web scraping, etc.).

Key concepts:
- MCP servers are configured in `~/.kollabor-cli/mcp/mcp_settings.json`
- Each EngineSession has its own MCPIntegration instance
- Servers communicate via stdio using JSON-RPC 2.0
- Tools are discovered at session initialization via `tools/list`


## Architecture Decision: Global vs Per-Session

**DECISION: Per-Session MCP Management**

Rationale:
1. MCP servers are stdio processes - each session needs its own connections
2. Session lifecycle naturally aligns with MCP connection lifecycle
3. No shared state complexity between sessions
4. Matches existing EngineSession.mcp_integration design

However, global endpoints exist for:
- Listing available server configurations (read-only)
- Adding/removing server definitions from config file
- Session-specific operations take session_id parameter


## Server Configuration Schema

### MCPServerConfig

Stored in: `~/.kollabor-cli/mcp/mcp_settings.json` (global)
             `.kollabor-cli/mcp/mcp_settings.json` (local project)

```json
{
  "servers": {
    "memory": {
      "type": "stdio",
      "command": "npx -y @modelcontextprotocol/server-memory",
      "enabled": true,
      "description": "Persistent conversation memory",
      "env": {}
    },
    "filesystem": {
      "type": "stdio",
      "command": "npx -y @modelcontextprotocol/server-filesystem /Users/dev/projects",
      "enabled": true,
      "description": "File system access",
      "env": {}
    },
    "github": {
      "type": "stdio",
      "command": "npx -y @modelcontextprotocol/server-github",
      "enabled": false,
      "description": "GitHub API integration",
      "env": {
        "GITHUB_TOKEN": "ghp_..."
      }
    }
  }
}
```

### Schema Fields

| Field    | Type    | Required | Description                                      |
|----------|---------|----------|--------------------------------------------------|
| type     | string  | yes      | Must be "stdio" (only supported type)            |
| command  | string  | yes      | Full command to start server (including args)    |
| enabled  | boolean | no       | If false, server is skipped (default: true)       |
| description | string | no    | Human-readable description                        |
| env      | object  | no       | Environment variables for server process          |


## Endpoints

### 1. List Available Server Configurations

```
GET /mcp/servers
```

Lists all configured MCP servers from config files (global + local merged).
This is a read-only view of configuration, NOT connection status.

**Response:**
```json
{
  "servers": {
    "memory": {
      "type": "stdio",
      "command": "npx -y @modelcontextprotocol/server-memory",
      "enabled": true,
      "description": "Persistent conversation memory"
    },
    "filesystem": {
      "type": "stdio",
      "command": "npx -y @modelcontextprotocol/server-filesystem /Users/dev/projects",
      "enabled": true,
      "description": "File system access"
    },
    "github": {
      "type": "stdio",
      "command": "npx -y @modelcontextprotocol/server-github",
      "enabled": false,
      "description": "GitHub API integration"
    }
  },
  "total": 3,
  "enabled": 2
}
```

**Example curl:**
```bash
curl -H "Authorization: Bearer $(cat ~/.kollabor-cli/engine.token)" \
  http://127.0.0.1:7433/mcp/servers
```

---

### 2. Add Server Configuration

```
POST /mcp/servers
```

Adds a new MCP server configuration to the global config file.
Returns 409 if server name already exists.

**Request:**
```json
{
  "name": "brave-search",
  "type": "stdio",
  "command": "npx -y @modelcontextprotocol/server-brave-search",
  "enabled": false,
  "description": "Web search using Brave Search API",
  "env": {
    "BRAVE_API_KEY": "your-key-here"
  }
}
```

**Response (201 Created):**
```json
{
  "ok": true,
  "name": "brave-search",
  "config": {
    "type": "stdio",
    "command": "npx -y @modelcontextprotocol/server-brave-search",
    "enabled": false,
    "description": "Web search using Brave Search API",
    "env": {
      "BRAVE_API_KEY": "your-key-here"
    }
  },
  "file": "~/.kollabor-cli/mcp/mcp_settings.json"
}
```

**Error Response (409 Conflict):**
```json
{
  "error": "ServerAlreadyExists",
  "message": "MCP server 'brave-search' already exists",
  "suggestion": "Use PUT /mcp/servers/{name} to update existing server"
}
```

**Error Response (400 Bad Request):**
```json
{
  "error": "InvalidConfiguration",
  "message": "Invalid MCP server configuration",
  "details": [
    "command: required field missing",
    "type: must be 'stdio'"
  ]
}
```

**Example curl:**
```bash
curl -X POST \
  -H "Authorization: Bearer $(cat ~/.kollabor-cli/engine.token)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "brave-search",
    "type": "stdio",
    "command": "npx -y @modelcontextprotocol/server-brave-search",
    "enabled": false,
    "description": "Web search",
    "env": {"BRAVE_API_KEY": "..."}
  }' \
  http://127.0.0.1:7433/mcp/servers
```

---

### 3. Remove Server Configuration

```
DELETE /mcp/servers/{name}
```

Removes an MCP server configuration from the config file.
Does NOT affect active sessions (they keep running connections).

**Response (200 OK):**
```json
{
  "ok": true,
  "name": "brave-search",
  "file": "~/.kollabor-cli/mcp/mcp_settings.json",
  "note": "Active sessions retain connection until shutdown"
}
```

**Error Response (404 Not Found):**
```json
{
  "error": "ServerNotFound",
  "message": "MCP server 'nonexistent' not found"
}
```

**Example curl:**
```bash
curl -X DELETE \
  -H "Authorization: Bearer $(cat ~/.kollabor-cli/engine.token)" \
  http://127.0.0.1:7433/mcp/servers/brave-search
```

---

### 4. Update Server Configuration

```
PUT /mcp/servers/{name}
```

Updates an existing MCP server configuration.

**Request:**
```json
{
  "command": "npx -y @modelcontextprotocol/server-brave-search",
  "enabled": true,
  "description": "Updated description",
  "env": {
    "BRAVE_API_KEY": "new-key"
  }
}
```

**Response (200 OK):**
```json
{
  "ok": true,
  "name": "brave-search",
  "config": { ...updated config... }
}
```

---

### 5. Get Session MCP Status

```
GET /sessions/{session_id}/mcp
```

Shows MCP server connection status for a specific session.
This is the per-session view of active connections.

**Response:**
```json
{
  "session_id": "sess_abc123",
  "servers": {
    "memory": {
      "status": "connected",
      "connected_at": "2026-02-17T12:00:00Z",
      "tool_count": 4,
      "tools": ["query_memory", "create_memory", "update_memory", "delete_memory"]
    },
    "filesystem": {
      "status": "connected",
      "connected_at": "2026-02-17T12:00:01Z",
      "tool_count": 6,
      "tools": ["read_file", "write_file", "create_directory", "list_directory", "move_file", "search_files"]
    },
    "github": {
      "status": "disconnected",
      "error": "Server disabled in configuration"
    }
  },
  "total_tools": 10
}
```

**Status values:**
- `connected` - Server process running, initialized, tools registered
- `connecting` - Connection attempt in progress
- `disconnected` - Not connected (either not attempted or failed)
- `error` - Connection failed, check error field

---

### 6. Connect to Server (Session)

```
POST /sessions/{session_id}/mcp/{server_name}/connect
```

Initiates connection to an MCP server for the specified session.
Normally happens automatically during session initialization if server
is in mcp_servers list. This endpoint allows manual reconnection.

**Request:** (empty body)

**Response (202 Accepted):**
```json
{
  "ok": true,
  "server_name": "memory",
  "status": "connecting",
  "session_id": "sess_abc123"
}
```

**Error Response (404 Not Found):**
```json
{
  "error": "ServerNotFound",
  "message": "MCP server 'nonexistent' not found in configuration"
}
```

**Error Response (409 Conflict):**
```json
{
  "error": "AlreadyConnected",
  "message": "Server 'memory' is already connected for this session"
}
```

**Error Response (500 Internal Server Error):**
```json
{
  "error": "ConnectionFailed",
  "message": "Failed to connect to MCP server",
  "details": "Command 'npx -y @modelcontextprotocol/server-memory' failed: exit code 127"
}
```

---

### 7. Disconnect from Server (Session)

```
POST /sessions/{session_id}/mcp/{server_name}/disconnect
```

Closes the MCP server connection for a session.
Tools from this server will no longer be available.

**Request:** (empty body)

**Response (200 OK):**
```json
{
  "ok": true,
  "server_name": "memory",
  "status": "disconnected",
  "tools_removed": 4,
  "session_id": "sess_abc123"
}
```

---

### 8. List Server Tools

```
GET /sessions/{session_id}/mcp/{server_name}/tools
```

Lists all tools provided by a connected MCP server.
Tools are returned in OpenAI function format (compatible with Anthropic too).

**Response (200 OK):**
```json
{
  "server_name": "memory",
  "status": "connected",
  "tools": [
    {
      "name": "query_memory",
      "description": "Query existing memories",
      "parameters": {
        "type": "object",
        "properties": {
          "query": {
            "type": "string",
            "description": "The query to search for"
          }
        },
        "required": ["query"]
      }
    },
    {
      "name": "create_memory",
      "description": "Create a new memory",
      "parameters": {
        "type": "object",
        "properties": {
          "content": {
            "type": "string",
            "description": "The memory content"
          },
          "metadata": {
            "type": "object",
            "description": "Optional metadata"
          }
        },
        "required": ["content"]
      }
    }
  ],
  "total": 2
}
```

**Note:** These match the format from `MCPIntegration.get_tool_definitions_for_api()`.

---

### 9. Get Server Status

```
GET /sessions/{session_id}/mcp/{server_name}/status
```

Detailed status of a single MCP server connection.

**Response (200 OK):**
```json
{
  "server_name": "memory",
  "status": "connected",
  "connected_at": "2026-02-17T12:00:00Z",
  "uptime_seconds": 3600,
  "tool_count": 4,
  "tools": ["query_memory", "create_memory", "update_memory", "delete_memory"],
  "process": {
    "pid": 12345,
    "command": "npx -y @modelcontextprotocol/server-memory"
  },
  "error": null
}
```

**Error Response (Not Connected):**
```json
{
  "server_name": "memory",
  "status": "error",
  "error": "Connection failed: Command not found",
  "connected_at": null,
  "uptime_seconds": 0,
  "tool_count": 0,
  "tools": []
}
```

---

## Implementation Notes

### File: `packages/kollabor-engine/src/kollabor_engine/routes/mcp.py`

```python
"""MCP server management routes."""

import json
import logging
from pathlib import Path
from typing import Dict, List, Optional

from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel

from ..server import get_session_registry

logger = logging.getLogger(__name__)
router = APIRouter(prefix="/mcp", tags=["mcp"])

# Global MCP config paths
GLOBAL_MCP_DIR = Path.home() / ".kollabor-cli" / "mcp"
GLOBAL_MCP_CONFIG = GLOBAL_MCP_DIR / "mcp_settings.json"


class MCPServerConfig(BaseModel):
    """MCP server configuration."""
    type: str = "stdio"
    command: str
    enabled: bool = True
    description: str = ""
    env: Dict[str, str] = {}


class AddServerRequest(BaseModel):
    """Request to add a new server."""
    name: str
    type: str = "stdio"
    command: str
    enabled: bool = True
    description: str = ""
    env: Dict[str, str] = {}


def _load_mcp_config() -> Dict[str, Dict]:
    """Load global MCP config."""
    if not GLOBAL_MCP_CONFIG.exists():
        return {"servers": {}}
    try:
        with open(GLOBAL_MCP_CONFIG, "r") as f:
            return json.load(f)
    except json.JSONDecodeError as e:
        raise HTTPException(
            status_code=500,
            detail=f"Invalid JSON in MCP config: {e}"
        )


def _save_mcp_config(config: Dict) -> None:
    """Save global MCP config."""
    GLOBAL_MCP_DIR.mkdir(parents=True, exist_ok=True)
    with open(GLOBAL_MCP_CONFIG, "w") as f:
        json.dump(config, f, indent=2)


@router.get("/servers")
async def list_servers():
    """List all configured MCP servers."""
    config = _load_mcp_config()
    servers = config.get("servers", {})

    enabled_count = sum(1 for s in servers.values() if s.get("enabled", True))

    return {
        "servers": servers,
        "total": len(servers),
        "enabled": enabled_count
    }


@router.post("/servers", status_code=status.HTTP_201_CREATED)
async def add_server(body: AddServerRequest):
    """Add a new MCP server configuration."""
    config = _load_mcp_config()
    servers = config.get("servers", {})

    if body.name in servers:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail={
                "error": "ServerAlreadyExists",
                "message": f"MCP server '{body.name}' already exists",
                "suggestion": "Use PUT /mcp/servers/{name} to update"
            }
        )

    # Validate
    if body.type != "stdio":
        raise HTTPException(
            status_code=400,
            detail="Only 'stdio' type is currently supported"
        )

    servers[body.name] = {
        "type": body.type,
        "command": body.command,
        "enabled": body.enabled,
        "description": body.description,
        "env": body.env
    }

    config["servers"] = servers
    _save_mcp_config(config)

    return {
        "ok": True,
        "name": body.name,
        "config": servers[body.name],
        "file": str(GLOBAL_MCP_CONFIG)
    }


@router.delete("/servers/{server_name}")
async def remove_server(server_name: str):
    """Remove an MCP server configuration."""
    config = _load_mcp_config()
    servers = config.get("servers", {})

    if server_name not in servers:
        raise HTTPException(
            status_code=404,
            detail={
                "error": "ServerNotFound",
                "message": f"MCP server '{server_name}' not found"
            }
        )

    del servers[server_name]
    config["servers"] = servers
    _save_mcp_config(config)

    return {
        "ok": True,
        "name": server_name,
        "file": str(GLOBAL_MCP_CONFIG),
        "note": "Active sessions retain connection until shutdown"
    }


@router.put("/servers/{server_name}")
async def update_server(server_name: str, body: MCPServerConfig):
    """Update an existing MCP server configuration."""
    config = _load_mcp_config()
    servers = config.get("servers", {})

    if server_name not in servers:
        raise HTTPException(
            status_code=404,
            detail={
                "error": "ServerNotFound",
                "message": f"MCP server '{server_name}' not found"
            }
        )

    servers[server_name] = body.model_dump(exclude_unset=True)
    config["servers"] = servers
    _save_mcp_config(config)

    return {
        "ok": True,
        "name": server_name,
        "config": servers[server_name]
    }


# Session-specific endpoints

@router.get("/sessions/{session_id}/mcp")
async def get_session_mcp(session_id: str):
    """Get MCP status for a session."""
    registry = get_session_registry()
    session = registry.get(session_id)

    if not session:
        raise HTTPException(status_code=404, detail="Session not found")

    mcp = session.mcp_integration
    connections = mcp.server_connections
    tools = mcp.tool_registry

    result = {"session_id": session_id, "servers": {}}

    for server_name, server_config in mcp.mcp_servers.items():
        conn = connections.get(server_name)
        server_tools = [name for name, info in tools.items()
                       if info.get("server") == server_name]

        if conn and conn.initialized:
            result["servers"][server_name] = {
                "status": "connected",
                "tool_count": len(server_tools),
                "tools": server_tools
            }
        elif server_config.get("enabled", True):
            result["servers"][server_name] = {
                "status": "disconnected",
                "error": "Connection not attempted or failed"
            }
        else:
            result["servers"][server_name] = {
                "status": "disconnected",
                "error": "Server disabled in configuration"
            }

    result["total_tools"] = len(tools)
    return result


@router.post("/sessions/{session_id}/mcp/{server_name}/connect")
async def connect_server(session_id: str, server_name: str):
    """Connect to an MCP server for a session."""
    registry = get_session_registry()
    session = registry.get(session_id)

    if not session:
        raise HTTPException(status_code=404, detail="Session not found")

    mcp = session.mcp_integration

    if server_name not in mcp.mcp_servers:
        raise HTTPException(
            status_code=404,
            detail=f"MCP server '{server_name}' not found in configuration"
        )

    if server_name in mcp.server_connections:
        conn = mcp.server_connections[server_name]
        if conn.initialized:
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail=f"Server '{server_name}' is already connected"
            )

    # Trigger connection
    server_config = mcp.mcp_servers[server_name]
    if not server_config.get("enabled", True):
        raise HTTPException(
            status_code=400,
            detail=f"Server '{server_name}' is disabled"
        )

    # This would call mcp._connect_and_list_tools
    # For now, return accepted (actual connection is async)
    return {
        "ok": True,
        "server_name": server_name,
        "status": "connecting",
        "session_id": session_id
    }


@router.post("/sessions/{session_id}/mcp/{server_name}/disconnect")
async def disconnect_server(session_id: str, server_name: str):
    """Disconnect from an MCP server."""
    registry = get_session_registry()
    session = registry.get(session_id)

    if not session:
        raise HTTPException(status_code=404, detail="Session not found")

    mcp = session.mcp_integration

    if server_name not in mcp.server_connections:
        raise HTTPException(
            status_code=404,
            detail=f"No active connection to '{server_name}'"
        )

    # Count tools to be removed
    tools_to_remove = [
        name for name, info in mcp.tool_registry.items()
        if info.get("server") == server_name
    ]

    # Close connection
    await mcp.server_connections[server_name].close()
    del mcp.server_connections[server_name]

    # Remove tools
    for tool_name in tools_to_remove:
        del mcp.tool_registry[tool_name]

    return {
        "ok": True,
        "server_name": server_name,
        "status": "disconnected",
        "tools_removed": len(tools_to_remove),
        "session_id": session_id
    }


@router.get("/sessions/{session_id}/mcp/{server_name}/tools")
async def list_server_tools(session_id: str, server_name: str):
    """List tools from a connected MCP server."""
    registry = get_session_registry()
    session = registry.get(session_id)

    if not session:
        raise HTTPException(status_code=404, detail="Session not found")

    mcp = session.mcp_integration

    tools = []
    for tool_name, tool_info in mcp.tool_registry.items():
        if tool_info.get("server") == server_name:
            definition = tool_info.get("definition", {})
            tools.append({
                "name": tool_name,
                "description": definition.get("description", ""),
                "parameters": definition.get(
                    "parameters",
                    definition.get("inputSchema", {})
                )
            })

    return {
        "server_name": server_name,
        "status": "connected" if tools else "disconnected",
        "tools": tools,
        "total": len(tools)
    }
```

---

## Testing Considerations

### Unit Tests

1. **Config File Tests**
   - Test loading valid/invalid JSON
   - Test merging global + local configs
   - Test validation errors (missing command, wrong type)

2. **Server Management Tests**
   - Add server with valid config
   - Add duplicate server (409 conflict)
   - Add server with invalid config (400 bad request)
   - Remove existing server
   - Remove non-existent server (404)
   - Update server config

3. **Session Tests**
   - Connect to enabled server
   - Connect to disabled server (400)
   - Connect to non-existent server (404)
   - Disconnect connected server
   - Disconnect non-connected server (404)
   - List tools from connected server
   - List tools from disconnected server (empty list)

### Integration Tests

```bash
# Test adding a server
curl -X POST http://127.0.0.1:7433/mcp/servers \
  -H "Content-Type: application/json" \
  -d '{"name": "test-memory", "command": "npx -y @modelcontextprotocol/server-memory"}'

# Verify it appears in list
curl http://127.0.0.1:7433/mcp/servers

# Create session with the server
curl -X POST http://127.0.0.1:7433/sessions \
  -d '{"mcp_servers": ["test-memory"]}'

# Check session MCP status
curl http://127.0.0.1:7433/sessions/{id}/mcp

# List tools
curl http://127.0.0.1:7433/sessions/{id}/mcp/test-memory/tools

# Clean up
curl -X DELETE http://127.0.0.1:7433/mcp/servers/test-memory
```

### Edge Cases

1. **Server Process Crashes**
   - Tool call should fail gracefully
   - Status should show "error"
   - Reconnect should be possible

2. **Invalid Server Command**
   - Connection fails at startup
   - Error message should indicate why

3. **Tool Name Conflicts**
   - Two servers provide same tool name
   - Document behavior (first wins? last wins? namespace prefix?)

4. **Concurrent Sessions**
   - Each session has independent MCP connections
   - Disconnect in one session doesn't affect others

---

## Common Server Configurations

### Memory Server

```json
{
  "name": "memory",
  "type": "stdio",
  "command": "npx -y @modelcontextprotocol/server-memory",
  "enabled": true
}
```

Tools: `query_memory`, `create_memory`, `update_memory`, `delete_memory`

### Filesystem Server

```json
{
  "name": "filesystem",
  "type": "stdio",
  "command": "npx -y @modelcontextprotocol/server-filesystem /Users/dev/projects",
  "enabled": true
}
```

Tools: `read_file`, `write_file`, `create_directory`, `list_directory`,
        `move_file`, `search_files`

### Git Server

```json
{
  "name": "git",
  "type": "stdio",
  "command": "npx -y @modelcontextprotocol/server-git --repository .",
  "enabled": true
}
```

Tools: `git_status`, `git_log`, `git_diff`, `git_commit`, `git_branch`

### GitHub Server

```json
{
  "name": "github",
  "type": "stdio",
  "command": "npx -y @modelcontextprotocol/server-github",
  "enabled": false,
  "env": {
    "GITHUB_TOKEN": "ghp_..."
  }
}
```

Tools: `create_issue`, `create_pull_request`, `push_files`,
        `search_issues_and_prs`, `create_or_update_file`

### Brave Search Server

```json
{
  "name": "brave-search",
  "type": "stdio",
  "command": "npx -y @modelcontextprotocol/server-brave-search",
  "enabled": false,
  "env": {
    "BRAVE_API_KEY": "..."
  }
}
```

Tools: `brave_web_search`

---

## Events (SSE)

When MCP connection status changes, events are emitted on the turn stream:

### mcp_server_connected
```json
{
  "type": "mcp_server_connected",
  "server_name": "memory",
  "tool_count": 4
}
```

### mcp_server_disconnected
```json
{
  "type": "mcp_server_disconnected",
  "server_name": "memory",
  "reason": "user_requested"
}
```

### mcp_server_error
```json
{
  "type": "mcp_server_error",
  "server_name": "github",
  "error": "Authentication failed: invalid token"
}
```

### mcp_tool_registered
```json
{
  "type": "mcp_tool_registered",
  "server_name": "memory",
  "tool_name": "query_memory",
  "description": "Query existing memories"
}
```
