= {
};
const isProviderKey = (value: unknown): value is ProviderKey =>
- typeof value === 'string' && ['openai', 'google', 'openrouter', 'ollama', 'anthropic', 'grok'].includes(value);
+ typeof value === 'string' && ['openai', 'google', 'openrouter', 'ollama', 'anthropic', 'grok', 'lmstudio'].includes(value);
// Default base URL for Ollama instances when not explicitly configured
const DEFAULT_OLLAMA_URL = 'http://host.docker.internal:11434/v1';
@@ -970,6 +974,10 @@ const manualTestConnection = async (
return 'missing';
}
+ case 'lmstudio':
+ // LM-Studio runs locally and doesn't require an API key
+ // Always return 'configured' since it's a local service
+ return 'configured';
case 'anthropic':
const hasAnthropicKey = hasApiCredential('ANTHROPIC_API_KEY');
const anthropicConnected = providerConnectionStatus['anthropic']?.connected || false;
@@ -1013,6 +1021,8 @@ const manualTestConnection = async (
providerAlertMessage = 'Local Ollama service detected. Click "Test Connection" to confirm model availability.';
providerAlertClassName = providerWarningAlertStyle;
}
+ } else if (activeProviderKey === 'lmstudio') {
+ // LM-Studio is a local service, no API key needed - no alert
} else if (activeProviderKey && selectedProviderStatus === 'missing') {
const providerName = providerDisplayNames[activeProviderKey] ?? activeProviderKey;
providerAlertMessage = `${providerName} API key is not configured. Add it in Settings > API Keys.`;
@@ -1291,13 +1301,14 @@ const manualTestConnection = async (
Select {activeSelection === 'chat' ? 'Chat' : 'Embedding'} Provider
{[
{ key: 'openai', name: 'OpenAI', logo: '/img/OpenAI.png', color: 'green' },
{ key: 'google', name: 'Google', logo: '/img/google-logo.svg', color: 'blue' },
{ key: 'openrouter', name: 'OpenRouter', logo: '/img/OpenRouter.png', color: 'cyan' },
{ key: 'ollama', name: 'Ollama', logo: '/img/Ollama.png', color: 'purple' },
+ { key: 'lmstudio', name: 'LM-Studio', logo: '/img/LM-Studio.svg', color: 'indigo' },
{ key: 'anthropic', name: 'Anthropic', logo: '/img/claude-logo.svg', color: 'orange' },
{ key: 'grok', name: 'Grok', logo: '/img/Grok.png', color: 'yellow' }
]
diff --git a/archon-ui-main/src/services/credentialsService.ts b/archon-ui-main/src/services/credentialsService.ts
index b2d2da52fa..a286ccfee8 100644
--- a/archon-ui-main/src/services/credentialsService.ts
+++ b/archon-ui-main/src/services/credentialsService.ts
@@ -22,6 +22,7 @@ export interface RagSettings {
LLM_INSTANCE_NAME?: string;
OLLAMA_EMBEDDING_URL?: string;
OLLAMA_EMBEDDING_INSTANCE_NAME?: string;
+ LMSTUDIO_BASE_URL?: string;
EMBEDDING_MODEL?: string;
EMBEDDING_PROVIDER?: string;
// Crawling Performance Settings
@@ -201,6 +202,7 @@ class CredentialsService {
LLM_INSTANCE_NAME: "",
OLLAMA_EMBEDDING_URL: "",
OLLAMA_EMBEDDING_INSTANCE_NAME: "",
+ LMSTUDIO_BASE_URL: "",
EMBEDDING_PROVIDER: "openai",
EMBEDDING_MODEL: "",
// Crawling Performance Settings defaults
@@ -233,6 +235,7 @@ class CredentialsService {
"LLM_INSTANCE_NAME",
"OLLAMA_EMBEDDING_URL",
"OLLAMA_EMBEDDING_INSTANCE_NAME",
+ "LMSTUDIO_BASE_URL",
"EMBEDDING_PROVIDER",
"EMBEDDING_MODEL",
"CRAWL_WAIT_STRATEGY",
diff --git a/docs/docs/mcp-server.mdx b/docs/docs/mcp-server.mdx
index 944dde6405..1b243da873 100644
--- a/docs/docs/mcp-server.mdx
+++ b/docs/docs/mcp-server.mdx
@@ -78,11 +78,37 @@ AGENTS_BASE_URL=http://archon-agents:8052
# Authentication
MCP_SERVICE_KEY=your-service-key
+# Transport Configuration
+ARCHON_MCP_ENABLE_STREAMABLE_HTTP=true # Enable Streamable HTTP at /mcp (recommended)
+ARCHON_MCP_ENABLE_SSE=true # Enable SSE at /sse (legacy support)
+# Note: At least one transport must be enabled
+
# Unified Logging Configuration (Optional)
LOGFIRE_ENABLED=false # true=Logfire logging, false=standard logging
LOGFIRE_TOKEN=your-logfire-token # Only required when LOGFIRE_ENABLED=true
```
+### Transport Configuration
+
+Control which transport protocols the MCP server exposes:
+
+- **`ARCHON_MCP_ENABLE_STREAMABLE_HTTP`** (default: `true`): Enable modern Streamable HTTP transport at `/mcp`
+- **`ARCHON_MCP_ENABLE_SSE`** (default: `true`): Enable legacy SSE transport at `/sse`
+
+Both transports are enabled by default for maximum compatibility. You can disable one if you only need a single transport:
+
+```bash
+# Streamable HTTP only (recommended for new deployments)
+ARCHON_MCP_ENABLE_STREAMABLE_HTTP=true
+ARCHON_MCP_ENABLE_SSE=false
+
+# SSE only (legacy systems)
+ARCHON_MCP_ENABLE_STREAMABLE_HTTP=false
+ARCHON_MCP_ENABLE_SSE=true
+```
+
+> **Important**: At least one transport must be enabled. The server will fail to start if both are disabled.
+
### Docker Service
```yaml
@@ -95,34 +121,85 @@ archon-mcp:
command: ["python", "-m", "src.mcp.mcp_server"]
```
+## Transport Support
+
+Archon MCP server supports **dual transport** for maximum compatibility:
+
+### Available Transports
+
+| Transport | Endpoint | Status | Recommended |
+|-----------|----------|--------|-------------|
+| **Streamable HTTP** | `/mcp` | Active | ✅ Modern MCP clients |
+| **SSE** | `/sse` | Legacy | ⚠️ Backward compatibility only |
+
+### When to Use Each Transport
+
+**Use Streamable HTTP (`/mcp`) when:**
+- Using modern MCP clients (Claude Code, Claude Desktop, latest Cursor)
+- Starting new integrations
+- You want the latest MCP protocol features (2025-03-26 spec)
+
+**Use SSE (`/sse`) when:**
+- Maintaining existing integrations that rely on SSE
+- Using older MCP clients that don't support Streamable HTTP
+- You need backward compatibility with legacy systems
+
+> **Migration Note**: SSE transport is maintained for backward compatibility but is considered legacy. New integrations should use Streamable HTTP (`/mcp`).
+
## Client Configuration
-Archon MCP server uses **SSE (Server-Sent Events) transport only**.
+### Claude Code (Recommended)
-### Cursor IDE
+**Streamable HTTP (Recommended):**
+```bash
+claude mcp add archon http://localhost:8051/mcp
+```
+
+**SSE (Legacy):**
+```bash
+claude mcp add --transport sse archon http://localhost:8051/sse
+```
-Add to MCP settings:
+### Cursor IDE
+**Streamable HTTP (Recommended):**
```json
{
"mcpServers": {
"archon": {
- "uri": "http://localhost:8051/sse"
+ "url": "http://localhost:8051/mcp",
+ "transport": "streamable-http"
}
}
}
```
-### Claude Code
-
-```bash
-claude mcp add --transport sse archon http://localhost:8051/sse
+**SSE (Legacy):**
+```json
+{
+ "mcpServers": {
+ "archon": {
+ "uri": "http://localhost:8051/sse"
+ }
+ }
+}
```
### Windsurf IDE
-Add to settings:
+**Streamable HTTP (Recommended):**
+```json
+{
+ "mcp.servers": {
+ "archon": {
+ "url": "http://localhost:8051/mcp",
+ "transport": "streamable-http"
+ }
+ }
+}
+```
+**SSE (Legacy):**
```json
{
"mcp.servers": {
diff --git a/python/src/agents/base_agent.py b/python/src/agents/base_agent.py
index 7ea03c031f..61662e08e4 100644
--- a/python/src/agents/base_agent.py
+++ b/python/src/agents/base_agent.py
@@ -6,17 +6,64 @@
import asyncio
import logging
+import os
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Generic, TypeVar
+from openai import AsyncOpenAI
from pydantic import BaseModel
from pydantic_ai import Agent
+from pydantic_ai.models.openai import OpenAIChatModel
+from pydantic_ai.providers.openai import OpenAIProvider
logger = logging.getLogger(__name__)
+def _prepare_model_for_agent(model_string: str) -> str | OpenAIChatModel:
+ """
+ Prepare model string for PydanticAI Agent, handling LM-Studio provider.
+
+ PydanticAI doesn't have built-in support for "lmstudio:" prefix, but since
+ LM-Studio uses OpenAI-compatible API, we can create a custom OpenAI model
+ with the LM-Studio base URL.
+
+ Args:
+ model_string: Model string in format "provider:model-name" (e.g., "lmstudio:llama-3.2-1b-instruct")
+
+ Returns:
+ Either the original model string (for built-in providers) or a configured OpenAIChatModel
+ """
+ if not model_string or ":" not in model_string:
+ return model_string
+
+ provider, model_name = model_string.split(":", 1)
+
+ # Handle LM-Studio as a special case
+ if provider.lower() == "lmstudio":
+ # Get LM-Studio base URL from environment or use default
+ base_url = os.getenv("LM_STUDIO_BASE_URL", "http://host.docker.internal:1234/v1")
+
+ logger.info(f"Creating LM-Studio model with base_url: {base_url}, model: {model_name}")
+
+ # Create custom OpenAI-compatible client for LM-Studio
+ client = AsyncOpenAI(
+ api_key="lm-studio", # LM-Studio doesn't require a real API key
+ base_url=base_url
+ )
+
+ # Create OpenAIChatModel with custom provider
+ return OpenAIChatModel(
+ model_name,
+ provider=OpenAIProvider(openai_client=client)
+ )
+
+ # For all other providers (openai, anthropic, google, etc.), return as-is
+ # PydanticAI has built-in support for these
+ return model_string
+
+
@dataclass
class ArchonDependencies:
"""Base dependencies for all Archon agents."""
@@ -158,7 +205,8 @@ def __init__(
enable_rate_limiting: bool = True,
**agent_kwargs,
):
- self.model = model
+ # Prepare model for PydanticAI (handles LM-Studio and other custom providers)
+ self.model = _prepare_model_for_agent(model)
self.name = name or self.__class__.__name__
self.retries = retries
self.enable_rate_limiting = enable_rate_limiting
diff --git a/python/src/mcp_server/README.md b/python/src/mcp_server/README.md
new file mode 100644
index 0000000000..edf6e36b36
--- /dev/null
+++ b/python/src/mcp_server/README.md
@@ -0,0 +1,452 @@
+# Archon MCP Server Configuration Guide
+
+This guide explains how to configure and troubleshoot the Archon MCP (Model Context Protocol) server's dual transport support.
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Transport Options](#transport-options)
+- [Environment Variables](#environment-variables)
+- [When to Use Each Transport](#when-to-use-each-transport)
+- [Client Configuration](#client-configuration)
+- [Troubleshooting](#troubleshooting)
+- [Development](#development)
+
+## Overview
+
+The Archon MCP server provides AI clients with access to Archon's functionality through the Model Context Protocol. It supports two transport options:
+
+- **Streamable HTTP** (`/mcp`): Modern transport supporting the latest MCP specification (2025-03-26)
+- **SSE** (`/sse`): Legacy Server-Sent Events transport for backward compatibility
+
+Both transports:
+- Share the same FastMCP instance and tools
+- Use the same lifespan context (sessions, connections)
+- Provide identical functionality
+- Can run simultaneously for maximum compatibility
+
+## Transport Options
+
+### Streamable HTTP (`/mcp`)
+
+**Status**: Active ✅ | **Recommended**: Yes
+
+The modern MCP transport that replaces SSE in the 2025-03-26 protocol specification.
+
+**Features:**
+- HTTP POST for bidirectional communication
+- Single connection model (simpler than SSE)
+- Better error handling
+- Native support in modern MCP clients
+
+**Use for:**
+- Claude Code
+- Claude Desktop
+- Latest Cursor IDE versions
+- Windsurf IDE
+- New integrations
+
+### SSE (`/sse`)
+
+**Status**: Legacy ⚠️ | **Recommended**: No
+
+Server-Sent Events transport maintained for backward compatibility with older MCP clients.
+
+**Features:**
+- HTTP + Server-Sent Events
+- Streaming responses
+- Deprecated as of MCP protocol 2025-03-26
+
+**Use for:**
+- Existing integrations that rely on SSE
+- Older MCP clients without Streamable HTTP support
+- Systems that require SSE specifically
+
+> **Migration Path**: If you're currently using SSE, plan to migrate to Streamable HTTP. SSE will continue to work but won't receive new MCP protocol features.
+
+## Environment Variables
+
+### Transport Configuration
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `ARCHON_MCP_ENABLE_STREAMABLE_HTTP` | `true` | Enable Streamable HTTP transport at `/mcp` |
+| `ARCHON_MCP_ENABLE_SSE` | `true` | Enable SSE transport at `/sse` |
+| `ARCHON_MCP_PORT` | `8051` | Port for the MCP server |
+
+**Important**: At least one transport must be enabled. The server will fail to start if both are set to `false`.
+
+### Server Connection
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `API_BASE_URL` | `http://archon-server:8080` | Archon Server API base URL |
+| `AGENTS_BASE_URL` | `http://archon-agents:8052` | Archon Agents API base URL |
+| `MCP_SERVICE_KEY` | *(required)* | Service key for authentication |
+
+### Logging Configuration
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `LOGFIRE_ENABLED` | `false` | Enable Logfire logging |
+| `LOGFIRE_TOKEN` | *(optional)* | Logfire token (required if enabled) |
+
+## When to Use Each Transport
+
+### Choose Streamable HTTP When:
+
+✅ **Starting a new integration** - It's the modern standard
+✅ **Using Claude Code or Claude Desktop** - Native support
+✅ **Using latest Cursor or Windsurf** - Better performance
+✅ **You want future protocol features** - SSE is frozen
+
+### Choose SSE When:
+
+⚠️ **Maintaining existing SSE integrations** - Avoid breaking changes
+⚠️ **Using older MCP clients** - That don't support Streamable HTTP
+⚠️ **Testing legacy compatibility** - Validation purposes
+
+### Enable Both When:
+
+🔄 **Gradual migration** - Supporting both old and new clients
+🔄 **Maximum compatibility** - Development environments
+🔄 **Testing both transports** - Quality assurance
+
+## Client Configuration
+
+### Claude Code
+
+**Streamable HTTP (Recommended):**
+```bash
+claude mcp add archon http://localhost:8051/mcp
+```
+
+**SSE (Legacy):**
+```bash
+claude mcp add --transport sse archon http://localhost:8051/sse
+```
+
+**Verify connection:**
+```bash
+claude mcp list
+```
+
+### Cursor IDE
+
+**Streamable HTTP (Recommended):**
+
+Add to Cursor settings (`~/.cursor/mcp_settings.json` or IDE settings):
+
+```json
+{
+ "mcpServers": {
+ "archon": {
+ "url": "http://localhost:8051/mcp",
+ "transport": "streamable-http"
+ }
+ }
+}
+```
+
+**SSE (Legacy):**
+```json
+{
+ "mcpServers": {
+ "archon": {
+ "uri": "http://localhost:8051/sse"
+ }
+ }
+}
+```
+
+### Windsurf IDE
+
+**Streamable HTTP (Recommended):**
+```json
+{
+ "mcp.servers": {
+ "archon": {
+ "url": "http://localhost:8051/mcp",
+ "transport": "streamable-http"
+ }
+ }
+}
+```
+
+**SSE (Legacy):**
+```json
+{
+ "mcp.servers": {
+ "archon": {
+ "uri": "http://localhost:8051/sse"
+ }
+ }
+}
+```
+
+### PydanticAI (Programmatic)
+
+**Streamable HTTP:**
+```python
+from pydantic_ai import Agent
+from pydantic_ai.mcp import MCPServerStreamableHTTP
+
+server = MCPServerStreamableHTTP('http://localhost:8051/mcp')
+agent = Agent('openai:gpt-4', toolsets=[server])
+```
+
+**SSE:**
+```python
+from pydantic_ai import Agent
+from pydantic_ai.mcp import MCPServerSSE
+
+server = MCPServerSSE('http://localhost:8051/sse')
+agent = Agent('openai:gpt-4', toolsets=[server])
+```
+
+## Troubleshooting
+
+### Server Won't Start
+
+**Error**: `ValueError: At least one transport must be enabled`
+
+**Solution**: Enable at least one transport:
+```bash
+export ARCHON_MCP_ENABLE_STREAMABLE_HTTP=true
+# OR
+export ARCHON_MCP_ENABLE_SSE=true
+```
+
+### Client Can't Connect
+
+**Symptom**: Connection refused or timeout
+
+**Check**:
+1. Is the server running?
+ ```bash
+ docker ps | grep archon-mcp
+ # OR
+ curl http://localhost:8051/mcp
+ ```
+
+2. Is the correct port exposed?
+ ```bash
+ docker port archon-mcp
+ # Should show: 8051/tcp -> 0.0.0.0:8051
+ ```
+
+3. Is the firewall blocking the port?
+ ```bash
+ # macOS
+ sudo lsof -i :8051
+
+ # Linux
+ sudo netstat -tlnp | grep 8051
+ ```
+
+### Wrong Transport Type
+
+**Symptom**: Client shows "Not Acceptable" or "Unsupported transport"
+
+**Solution**: Check your client configuration matches the server endpoint:
+
+| Endpoint | Transport Type | Client Config |
+|----------|---------------|---------------|
+| `/mcp` | Streamable HTTP | Use `url` or `transport: streamable-http` |
+| `/sse` | SSE | Use `uri` or `transport: sse` |
+
+**Example Fix**:
+```json
+// WRONG - mixing transport types
+{
+ "archon": {
+ "uri": "http://localhost:8051/mcp" // ❌ uri is for SSE
+ }
+}
+
+// CORRECT
+{
+ "archon": {
+ "url": "http://localhost:8051/mcp" // ✅ url for Streamable HTTP
+ }
+}
+```
+
+### Session Errors
+
+**Symptom**: "Missing session ID" or "Invalid session"
+
+**Cause**: The MCP protocol requires session initialization via the `initialize` method.
+
+**Solution**: Most MCP clients handle this automatically. If you're implementing a custom client:
+
+1. Send `initialize` request first:
+ ```json
+ {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {},
+ "clientInfo": {"name": "my-client", "version": "1.0"}
+ }
+ }
+ ```
+
+2. Store the session ID from response
+3. Include session ID in subsequent requests (header or params, depending on transport)
+
+### Tools Not Available
+
+**Symptom**: Client can't see MCP tools
+
+**Check**:
+1. Is the server fully started?
+ ```bash
+ docker logs archon-mcp | grep "Application startup complete"
+ ```
+
+2. Can you list tools via API?
+ ```bash
+ curl -X POST http://localhost:8051/mcp \
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
+ ```
+
+3. Check server logs for errors:
+ ```bash
+ docker logs archon-mcp --tail 100
+ ```
+
+### Performance Issues
+
+**Symptom**: Slow response times
+
+**Check**:
+1. Network latency:
+ ```bash
+ curl -w "@-" -o /dev/null -s http://localhost:8051/mcp <<'EOF'
+ time_namelookup: %{time_namelookup}s\n
+ time_connect: %{time_connect}s\n
+ time_total: %{time_total}s\n
+ EOF
+ ```
+
+2. Server resource usage:
+ ```bash
+ docker stats archon-mcp --no-stream
+ ```
+
+3. Backend API health:
+ ```bash
+ curl http://localhost:8181/api/health
+ ```
+
+**Optimization**:
+- Use Streamable HTTP instead of SSE (lower overhead)
+- Enable connection pooling in your client
+- Check backend API performance (MCP is just a proxy)
+
+## Development
+
+### Running Locally
+
+**With Docker:**
+```bash
+docker run -d \
+ --name archon-mcp \
+ -p 8051:8051 \
+ -e ARCHON_MCP_PORT=8051 \
+ -e ARCHON_MCP_ENABLE_STREAMABLE_HTTP=true \
+ -e ARCHON_MCP_ENABLE_SSE=true \
+ -e API_BASE_URL=http://host.docker.internal:8181 \
+ --env-file .env \
+ archon-mcp:latest
+```
+
+**With Python:**
+```bash
+cd python
+export ARCHON_MCP_PORT=8051
+export ARCHON_MCP_ENABLE_STREAMABLE_HTTP=true
+export ARCHON_MCP_ENABLE_SSE=true
+python -m src.mcp_server.mcp_server
+```
+
+### Testing Both Transports
+
+**Quick health check:**
+```bash
+# Streamable HTTP
+curl -X POST http://localhost:8051/mcp \
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json, text/event-stream" \
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
+
+# SSE
+curl -X POST http://localhost:8051/sse \
+ -H "Content-Type: application/json" \
+ -H "Accept: text/event-stream" \
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
+```
+
+### Configuration Examples
+
+**Streamable HTTP only (production recommended):**
+```bash
+ARCHON_MCP_ENABLE_STREAMABLE_HTTP=true
+ARCHON_MCP_ENABLE_SSE=false
+```
+
+**SSE only (legacy systems):**
+```bash
+ARCHON_MCP_ENABLE_STREAMABLE_HTTP=false
+ARCHON_MCP_ENABLE_SSE=true
+```
+
+**Both enabled (maximum compatibility):**
+```bash
+ARCHON_MCP_ENABLE_STREAMABLE_HTTP=true
+ARCHON_MCP_ENABLE_SSE=true
+```
+
+### Logs and Monitoring
+
+**View server logs:**
+```bash
+# Docker
+docker logs archon-mcp -f
+
+# Follow logs for both transports
+docker logs archon-mcp | grep -E "(Streamable HTTP|SSE)"
+```
+
+**Check which transports are enabled:**
+```bash
+docker logs archon-mcp 2>&1 | grep "Enabled:"
+# Output: Enabled: Streamable HTTP at /mcp, SSE at /sse
+```
+
+**Monitor tool calls:**
+```bash
+docker logs archon-mcp | grep "tools/call"
+```
+
+## Additional Resources
+
+- [MCP Protocol Specification](https://modelcontextprotocol.io/specification/)
+- [FastMCP Documentation](https://github.com/modelcontextprotocol/python-sdk)
+- [Archon MCP Server Documentation](../../docs/docs/mcp-server.mdx)
+- [Environment Variables Reference](../../.env.example)
+
+## Support
+
+If you encounter issues not covered in this guide:
+
+1. Check the [Archon GitHub Issues](https://github.com/yourusername/archon/issues)
+2. Review server logs: `docker logs archon-mcp`
+3. Verify your environment variables
+4. Test with curl to isolate client vs server issues
+5. Enable debug logging: `LOGFIRE_ENABLED=true`
diff --git a/python/src/mcp_server/mcp_server.py b/python/src/mcp_server/mcp_server.py
index eac6040121..329c778ba9 100644
--- a/python/src/mcp_server/mcp_server.py
+++ b/python/src/mcp_server/mcp_server.py
@@ -31,6 +31,8 @@
from dotenv import load_dotenv
from mcp.server.fastmcp import Context, FastMCP
+import uvicorn
+from starlette.applications import Starlette
# Add the project root to Python path for imports
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -547,19 +549,80 @@ def register_modules():
def main():
- """Main entry point for the MCP server."""
+ """Main entry point for the MCP server with dual transport support."""
try:
# Initialize Logfire first
setup_logfire(service_name="archon-mcp-server")
+ # Read configuration for transport options
+ enable_sse = os.getenv("ARCHON_MCP_ENABLE_SSE", "true").lower() == "true"
+ enable_http = os.getenv("ARCHON_MCP_ENABLE_STREAMABLE_HTTP", "true").lower() == "true"
+
+ # Validate configuration
+ if not enable_sse and not enable_http:
+ raise ValueError(
+ "At least one transport must be enabled. "
+ "Set ARCHON_MCP_ENABLE_SSE=true or ARCHON_MCP_ENABLE_STREAMABLE_HTTP=true"
+ )
+
logger.info("🚀 Starting Archon MCP Server")
- logger.info(" Mode: Streamable HTTP")
- logger.info(f" URL: http://{server_host}:{server_port}/mcp")
+ logger.info(" Mode: Dual Transport")
+
+ enabled_transports = []
+ if enable_http:
+ enabled_transports.append("Streamable HTTP at /mcp")
+ if enable_sse:
+ enabled_transports.append("SSE at /sse")
+
+ logger.info(f" Enabled: {', '.join(enabled_transports)}")
+ logger.info(f" URL: http://{server_host}:{server_port}")
mcp_logger.info("🔥 Logfire initialized for MCP server")
- mcp_logger.info(f"🌟 Starting MCP server - host={server_host}, port={server_port}")
+ mcp_logger.info(f"🌟 Starting MCP server - host={server_host}, port={server_port}, transports={enabled_transports}")
+
+ # Create ASGI apps for enabled transports
+ # We need to use one of the apps directly since they already include
+ # the routes and lifespan context configured by FastMCP
+
+ if enable_http and enable_sse:
+ # Both enabled: create primary app with streamable HTTP
+ # and manually add SSE routes to it
+ logger.info("✓ Creating Streamable HTTP app at /mcp")
+ app = mcp.streamable_http_app()
+ logger.info("✓ Streamable HTTP transport configured")
+
+ logger.info("✓ Adding SSE routes at /sse")
+ sse_app = mcp.sse_app()
+ # Add SSE routes to the combined app
+ app.routes.extend(sse_app.routes)
+ logger.info("✓ SSE transport configured")
+
+ elif enable_http:
+ # Only streamable HTTP
+ logger.info("✓ Creating Streamable HTTP app at /mcp")
+ app = mcp.streamable_http_app()
+ logger.info("✓ Streamable HTTP transport configured")
- mcp.run(transport="streamable-http")
+ else:
+ # Only SSE
+ logger.info("✓ Creating SSE app at /sse")
+ app = mcp.sse_app()
+ logger.info("✓ SSE transport configured")
+
+ logger.info("✓ Combined transport app created")
+ logger.info(f"📡 Server starting on http://{server_host}:{server_port}")
+ if enable_http:
+ logger.info(f" → Streamable HTTP: http://{server_host}:{server_port}/mcp")
+ if enable_sse:
+ logger.info(f" → SSE: http://{server_host}:{server_port}/sse")
+
+ # Run with uvicorn
+ uvicorn.run(
+ app,
+ host=server_host,
+ port=server_port,
+ log_level="info",
+ )
except Exception as e:
mcp_logger.error(f"💥 Fatal error in main - error={str(e)}, error_type={type(e).__name__}")
diff --git a/python/src/server/api_routes/knowledge_api.py b/python/src/server/api_routes/knowledge_api.py
index 052f75216e..b939e7f60e 100644
--- a/python/src/server/api_routes/knowledge_api.py
+++ b/python/src/server/api_routes/knowledge_api.py
@@ -69,7 +69,7 @@ async def _validate_provider_api_key(provider: str = None) -> None:
provider = "openai"
else:
# Simple provider validation
- allowed_providers = {"openai", "ollama", "google", "openrouter", "anthropic", "grok"}
+ allowed_providers = {"openai", "ollama", "google", "openrouter", "anthropic", "grok", "lmstudio"}
if provider not in allowed_providers:
raise HTTPException(
status_code=400,
diff --git a/python/src/server/api_routes/mcp_api.py b/python/src/server/api_routes/mcp_api.py
index 5c9c605dd8..e1f2ad5b30 100644
--- a/python/src/server/api_routes/mcp_api.py
+++ b/python/src/server/api_routes/mcp_api.py
@@ -96,7 +96,7 @@ async def get_status():
@router.get("/config")
async def get_mcp_config():
- """Get MCP server configuration."""
+ """Get MCP server configuration with dual transport support."""
with safe_span("api_get_mcp_config") as span:
safe_set_attribute(span, "endpoint", "/api/mcp/config")
safe_set_attribute(span, "method", "GET")
@@ -106,12 +106,41 @@ async def get_mcp_config():
# Get actual MCP port from environment or use default
mcp_port = int(os.getenv("ARCHON_MCP_PORT", "8051"))
-
- # Configuration for streamable-http mode with actual port
+ host = os.getenv("ARCHON_HOST", "localhost")
+
+ # Check which transports are enabled
+ enable_sse = os.getenv("ARCHON_MCP_ENABLE_SSE", "true").lower() == "true"
+ enable_http = os.getenv("ARCHON_MCP_ENABLE_STREAMABLE_HTTP", "true").lower() == "true"
+
+ # Build transport endpoints array
+ transport_endpoints = []
+
+ if enable_http:
+ transport_endpoints.append({
+ "url": f"http://{host}:{mcp_port}/mcp",
+ "transport_type": "streamable-http",
+ "status": "enabled",
+ "recommended": True
+ })
+
+ if enable_sse:
+ transport_endpoints.append({
+ "url": f"http://{host}:{mcp_port}/sse",
+ "transport_type": "sse",
+ "status": "enabled",
+ "recommended": False,
+ "legacy": True
+ })
+
+ # Primary transport (prefer streamable-http)
+ primary_transport = "streamable-http" if enable_http else "sse"
+
+ # Configuration with dual transport support
config = {
- "host": os.getenv("ARCHON_HOST", "localhost"),
+ "host": host,
"port": mcp_port,
- "transport": "streamable-http",
+ "transport": primary_transport, # Backward compatibility
+ "transport_endpoints": transport_endpoints,
}
# Get only model choice from database (simplified)
@@ -126,10 +155,11 @@ async def get_mcp_config():
# Fallback to default model
config["model_choice"] = "gpt-4o-mini"
- api_logger.info("MCP configuration (streamable-http mode)")
+ api_logger.info(f"MCP configuration (dual transport mode) - enabled_transports={[e['transport_type'] for e in transport_endpoints]}")
safe_set_attribute(span, "host", config["host"])
safe_set_attribute(span, "port", config["port"])
- safe_set_attribute(span, "transport", "streamable-http")
+ safe_set_attribute(span, "primary_transport", primary_transport)
+ safe_set_attribute(span, "enabled_transports", len(transport_endpoints))
safe_set_attribute(span, "model_choice", config.get("model_choice", "gpt-4o-mini"))
return config
diff --git a/python/src/server/services/credential_service.py b/python/src/server/services/credential_service.py
index a8aee8491d..4c52bed7a9 100644
--- a/python/src/server/services/credential_service.py
+++ b/python/src/server/services/credential_service.py
@@ -443,7 +443,7 @@ async def get_active_provider(self, service_type: str = "llm") -> dict[str, Any]
explicit_embedding_provider = rag_settings.get("EMBEDDING_PROVIDER")
# Validate that embedding provider actually supports embeddings
- embedding_capable_providers = {"openai", "google", "ollama"}
+ embedding_capable_providers = {"openai", "google", "ollama", "lmstudio"}
if (explicit_embedding_provider and
explicit_embedding_provider != "" and
@@ -509,17 +509,20 @@ async def _get_provider_api_key(self, provider: str) -> str | None:
"anthropic": "ANTHROPIC_API_KEY",
"grok": "GROK_API_KEY",
"ollama": None, # No API key needed
+ "lmstudio": None, # No API key needed for local instance
}
key_name = key_mapping.get(provider)
if key_name:
return await self.get_credential(key_name)
- return "ollama" if provider == "ollama" else None
+ return "lm-studio" if provider == "lmstudio" else ("ollama" if provider == "ollama" else None)
def _get_provider_base_url(self, provider: str, rag_settings: dict) -> str | None:
"""Get base URL for provider."""
if provider == "ollama":
return rag_settings.get("LLM_BASE_URL", "http://host.docker.internal:11434/v1")
+ elif provider == "lmstudio":
+ return rag_settings.get("LMSTUDIO_BASE_URL", "http://host.docker.internal:1234/v1")
elif provider == "google":
return "https://generativelanguage.googleapis.com/v1beta/openai/"
elif provider == "openrouter":
diff --git a/python/src/server/services/llm_provider_service.py b/python/src/server/services/llm_provider_service.py
index 00197926fd..525cea7430 100644
--- a/python/src/server/services/llm_provider_service.py
+++ b/python/src/server/services/llm_provider_service.py
@@ -23,7 +23,7 @@ def _is_valid_provider(provider: str) -> bool:
"""Basic provider validation."""
if not provider or not isinstance(provider, str):
return False
- return provider.lower() in {"openai", "ollama", "google", "openrouter", "anthropic", "grok"}
+ return provider.lower() in {"openai", "ollama", "google", "openrouter", "anthropic", "grok", "lmstudio"}
def _sanitize_for_log(text: str) -> str:
@@ -496,6 +496,15 @@ async def get_llm_client(
)
logger.info("Grok client created successfully")
+ elif provider_name == "lmstudio":
+ # LM-Studio uses OpenAI-compatible API but runs locally
+ # API key is not required for local instances
+ client = openai.AsyncOpenAI(
+ api_key=api_key or "lm-studio", # LM-Studio doesn't require a real key
+ base_url=base_url or "http://host.docker.internal:1234/v1",
+ )
+ logger.info(f"LM-Studio client created successfully with base URL: {base_url or 'http://host.docker.internal:1234/v1'}")
+
else:
raise ValueError(f"Unsupported LLM provider: {provider_name}")
@@ -665,6 +674,10 @@ async def get_embedding_model(provider: str | None = None) -> str:
# Grok supports OpenAI and Google embedding models through their API
# Default to OpenAI's latest for compatibility
return "text-embedding-3-small"
+ elif provider_name == "lmstudio":
+ # LM-Studio uses local models with OpenAI-compatible API
+ # Common embedding models in LM-Studio
+ return "text-embedding-nomic-embed-text"
else:
# Fallback to OpenAI's model
return "text-embedding-3-small"
@@ -748,6 +761,11 @@ def is_valid_embedding_model_for_provider(model: str, provider: str) -> bool:
model_lower = model.lower()
ollama_patterns = ["nomic-embed", "all-minilm", "mxbai-embed", "embed"]
return any(pattern in model_lower for pattern in ollama_patterns)
+ elif provider_lower == "lmstudio":
+ # LM-Studio supports local models, typically with "embed" in the name
+ model_lower = model.lower()
+ lmstudio_patterns = ["embed", "nomic", "all-minilm", "bge", "gte"]
+ return any(pattern in model_lower for pattern in lmstudio_patterns)
else:
# For unknown providers, assume OpenAI compatibility
return is_openai_embedding_model(model)
@@ -791,6 +809,9 @@ def get_supported_embedding_models(provider: str) -> list[str]:
return openai_models + google_models
elif provider_lower == "ollama":
return ["nomic-embed-text", "all-minilm", "mxbai-embed-large"]
+ elif provider_lower == "lmstudio":
+ # LM-Studio supports various local embedding models
+ return ["text-embedding-nomic-embed-text", "nomic-embed-text", "all-minilm-l6-v2", "bge-small-en-v1.5", "gte-large"]
else:
# For unknown providers, assume OpenAI compatibility
return openai_models
diff --git a/python/tests/test_lmstudio_agent.py b/python/tests/test_lmstudio_agent.py
new file mode 100644
index 0000000000..83db89bc3e
--- /dev/null
+++ b/python/tests/test_lmstudio_agent.py
@@ -0,0 +1,151 @@
+"""
+Test script for verifying LM-Studio chat provider integration with PydanticAI agents.
+
+This test verifies:
+1. LM-Studio model string is correctly processed by base_agent
+2. Agent can be instantiated with lmstudio: prefix
+3. Model preparation creates proper OpenAIChatModel
+"""
+
+import asyncio
+import os
+import sys
+from pathlib import Path
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
+
+from agents.base_agent import _prepare_model_for_agent
+from agents.rag_agent import RagAgent, RagDependencies
+
+
+def test_lmstudio_model_preparation():
+ """Test that lmstudio: model strings are correctly processed."""
+ print("Testing LM-Studio model preparation...")
+
+ # Test with lmstudio prefix
+ model_string = "lmstudio:llama-3.2-1b-instruct"
+ result = _prepare_model_for_agent(model_string)
+
+ # Result should be an OpenAIChatModel object, not a string
+ from pydantic_ai.models.openai import OpenAIChatModel
+ assert isinstance(result, OpenAIChatModel), f"Expected OpenAIChatModel, got {type(result)}"
+ print(f"✓ LM-Studio model correctly prepared: {type(result).__name__}")
+
+ # Test with openai prefix (should pass through as string)
+ openai_string = "openai:gpt-4o"
+ result2 = _prepare_model_for_agent(openai_string)
+ assert isinstance(result2, str), f"Expected string for OpenAI, got {type(result2)}"
+ print(f"✓ OpenAI model correctly passed through: {result2}")
+
+ return True
+
+
+def test_rag_agent_instantiation():
+ """Test that RAG agent can be instantiated with LM-Studio model."""
+ print("\nTesting RAG agent instantiation with LM-Studio...")
+
+ # Set environment variable for base URL (if not already set)
+ if not os.getenv("LM_STUDIO_BASE_URL"):
+ os.environ["LM_STUDIO_BASE_URL"] = "http://localhost:1234/v1"
+
+ try:
+ # Create agent with LM-Studio model
+ agent = RagAgent(model="lmstudio:llama-3.2-1b-instruct")
+ print(f"✓ RAG agent created successfully with LM-Studio model")
+ print(f" Agent name: {agent.name}")
+ print(f" Model type: {type(agent.model).__name__}")
+
+ # Verify the model is properly configured
+ from pydantic_ai.models.openai import OpenAIChatModel
+ assert isinstance(agent.model, OpenAIChatModel), "Agent model should be OpenAIChatModel"
+ print(f"✓ Agent model is correctly configured as OpenAIChatModel")
+
+ return True
+ except Exception as e:
+ print(f"✗ Failed to create RAG agent: {e}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+
+async def test_lmstudio_connection_mock():
+ """
+ Mock test for LM-Studio connection.
+
+ Note: This test doesn't actually connect to LM-Studio (which may not be running),
+ but verifies that the agent configuration is correct.
+ """
+ print("\nTesting LM-Studio agent configuration (mock)...")
+
+ try:
+ # Create agent
+ agent = RagAgent(model="lmstudio:llama-3.2-1b-instruct")
+
+ # Verify agent's internal model configuration
+ from pydantic_ai.models.openai import OpenAIChatModel
+ assert isinstance(agent.model, OpenAIChatModel), "Model should be OpenAIChatModel"
+
+ # Check that the underlying PydanticAI agent was created
+ assert agent._agent is not None, "PydanticAI agent should be initialized"
+ print(f"✓ Agent configuration is valid for LM-Studio")
+
+ return True
+ except Exception as e:
+ print(f"✗ Agent configuration failed: {e}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+
+def main():
+ """Run all tests."""
+ print("=" * 60)
+ print("LM-Studio Chat Provider Integration Tests")
+ print("=" * 60)
+
+ results = []
+
+ # Test 1: Model preparation
+ try:
+ results.append(("Model Preparation", test_lmstudio_model_preparation()))
+ except Exception as e:
+ print(f"✗ Model preparation test failed: {e}")
+ results.append(("Model Preparation", False))
+
+ # Test 2: Agent instantiation
+ try:
+ results.append(("Agent Instantiation", test_rag_agent_instantiation()))
+ except Exception as e:
+ print(f"✗ Agent instantiation test failed: {e}")
+ results.append(("Agent Instantiation", False))
+
+ # Test 3: Mock connection test
+ try:
+ result = asyncio.run(test_lmstudio_connection_mock())
+ results.append(("Agent Configuration", result))
+ except Exception as e:
+ print(f"✗ Agent configuration test failed: {e}")
+ results.append(("Agent Configuration", False))
+
+ # Summary
+ print("\n" + "=" * 60)
+ print("Test Summary")
+ print("=" * 60)
+
+ for test_name, passed in results:
+ status = "✓ PASSED" if passed else "✗ FAILED"
+ print(f"{test_name}: {status}")
+
+ all_passed = all(passed for _, passed in results)
+
+ if all_passed:
+ print("\n🎉 All tests passed!")
+ return 0
+ else:
+ print("\n❌ Some tests failed")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/test_dual_transport.sh b/test_dual_transport.sh
new file mode 100755
index 0000000000..ae5bee3128
--- /dev/null
+++ b/test_dual_transport.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+# Test script for dual transport MCP server
+# CRITICAL: Uses port 8060 to avoid interfering with production Archon on port 8051
+
+set -e
+
+echo "🔍 Checking production Archon is still running..."
+docker ps --filter "name=archon-mcp" --format "{{.Names}} - {{.Status}}" | grep "archon-mcp" || {
+ echo "❌ ERROR: Production archon-mcp is not running!"
+ exit 1
+}
+echo "✅ Production Archon MCP is running on port 8051 (unchanged)"
+echo ""
+
+echo "🧪 Starting TEST MCP server on port 8060..."
+echo " Transport endpoints:"
+echo " → Streamable HTTP: http://localhost:8060/mcp"
+echo " → SSE: http://localhost:8060/sse"
+echo ""
+
+# Export test port
+export ARCHON_MCP_PORT=8060
+export ARCHON_MCP_ENABLE_SSE=true
+export ARCHON_MCP_ENABLE_STREAMABLE_HTTP=true
+
+# Load other environment variables from .env
+if [ -f .env ]; then
+ export $(grep -v '^#' .env | grep -v '^$' | xargs)
+fi
+
+# Re-export test port (override any .env setting)
+export ARCHON_MCP_PORT=8060
+
+echo "🚀 Launching test MCP server..."
+echo " (Press Ctrl+C to stop)"
+echo ""
+
+cd python
+python3 src/mcp_server/mcp_server.py
diff --git a/test_endpoints.sh b/test_endpoints.sh
new file mode 100755
index 0000000000..eaacbce213
--- /dev/null
+++ b/test_endpoints.sh
@@ -0,0 +1,74 @@
+#!/bin/bash
+# Test both MCP transport endpoints
+# Tests the dual transport implementation on port 8060
+
+set -e
+
+TEST_PORT=8060
+BASE_URL="http://localhost:${TEST_PORT}"
+
+echo "🧪 Testing Dual Transport MCP Server on port ${TEST_PORT}"
+echo "=================================================="
+echo ""
+
+# First verify production is untouched
+echo "🔒 SAFETY CHECK: Verifying production Archon (port 8051) is still running..."
+docker ps --filter "name=archon-mcp" --format "{{.Names}} - {{.Status}}" | grep "archon-mcp" || {
+ echo "❌ ERROR: Production archon-mcp is not running!"
+ exit 1
+}
+echo "✅ Production Archon MCP confirmed running on port 8051"
+echo ""
+
+# Test Streamable HTTP endpoint
+echo "📡 Test 1: Streamable HTTP transport at /mcp"
+echo " Endpoint: ${BASE_URL}/mcp"
+echo -n " Testing connection... "
+
+# Simple HTTP test
+HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" -X GET "${BASE_URL}/mcp" -H "Accept: application/json" 2>&1 || echo "ERROR")
+
+if echo "$HTTP_RESPONSE" | tail -1 | grep -qE "^(200|400|405)$"; then
+ echo "✅ Streamable HTTP endpoint responding"
+else
+ echo "⚠️ Response: $HTTP_RESPONSE"
+fi
+echo ""
+
+# Test SSE endpoint
+echo "📡 Test 2: SSE transport at /sse"
+echo " Endpoint: ${BASE_URL}/sse"
+echo -n " Testing connection... "
+
+SSE_RESPONSE=$(curl -s -w "\n%{http_code}" -X GET "${BASE_URL}/sse" -H "Accept: text/event-stream" 2>&1 || echo "ERROR")
+
+if echo "$SSE_RESPONSE" | tail -1 | grep -qE "^(200|400|405)$"; then
+ echo "✅ SSE endpoint responding"
+else
+ echo "⚠️ Response: $SSE_RESPONSE"
+fi
+echo ""
+
+# Test MCP API config endpoint (from server)
+echo "📡 Test 3: MCP API config endpoint"
+echo " Endpoint: http://localhost:8181/api/mcp/config"
+echo -n " Testing transport_endpoints field... "
+
+CONFIG_RESPONSE=$(curl -s http://localhost:8181/api/mcp/config)
+if echo "$CONFIG_RESPONSE" | grep -q "transport_endpoints"; then
+ echo "✅ Config endpoint includes transport_endpoints"
+ echo ""
+ echo " Response preview:"
+ echo "$CONFIG_RESPONSE" | python3 -m json.tool 2>/dev/null | head -20 || echo "$CONFIG_RESPONSE"
+else
+ echo "⚠️ transport_endpoints field not found"
+fi
+echo ""
+
+echo "=================================================="
+echo "✅ Testing complete!"
+echo ""
+echo "Summary:"
+echo " - Production Archon (port 8051): ✅ Running"
+echo " - Test server (port 8060): Check results above"
+echo ""