Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.

Commit ab88fb6

Browse files
committed
feat: implement hybrid MCP configuration support
- Added MCPServerConfig for Claude Desktop-style stdio MCP configuration (command, args, env). - Updated MCPClient to support explicit execution arguments and environment variable injection. - Implemented legacy MCP provider compatibility by parsing stdio:// URLs into MCPServerConfig. - Unified MCP lifecycle management in Router.reload_providers for both config styles.
1 parent 40621c8 commit ab88fb6

3 files changed

Lines changed: 69 additions & 32 deletions

File tree

plexir/core/config_manager.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,22 @@ class ProviderConfig(BaseModel):
2323
model_name: str
2424
base_url: Optional[str] = None
2525

26+
class MCPServerConfig(BaseModel):
27+
"""Configuration for a generic MCP server (std-io)."""
28+
command: str = Field(..., description="The executable command (e.g. 'node', 'python').")
29+
args: List[str] = Field(default_factory=list, description="List of arguments.")
30+
env: Dict[str, str] = Field(default_factory=dict, description="Environment variables.")
31+
disabled: bool = Field(False, description="Whether this server is disabled.")
32+
2633
class AppConfig(BaseModel):
2734
"""Top-level application configuration."""
2835
providers: List[ProviderConfig] = [
29-
ProviderConfig(name="Gemini Primary", type="gemini", model_name="gemini-3-flash-preview"),
36+
ProviderConfig(name="Gemini Primary", type="gemini", model_name="gemini-3-flash"),
3037
ProviderConfig(name="Gemini Fallback", type="gemini", model_name="gemini-2.5-flash"),
3138
ProviderConfig(name="Groq Backup", type="groq", model_name="openai/gpt-oss-120b"),
3239
]
3340
active_provider_order: List[str] = ["Gemini Primary", "Gemini Fallback", "Groq Backup"]
41+
mcp_servers: Dict[str, MCPServerConfig] = Field(default_factory=dict, description="Configured MCP servers.")
3442
theme: str = "tokyo-night"
3543
debug_mode: bool = False
3644
session_budget: float = 0.0 # 0.0 means no limit

plexir/core/router.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,12 +131,39 @@ async def reload_providers(self):
131131
elif p_config.type in ["openai", "groq", "ollama"]:
132132
self.providers.append(OpenAICompatibleProvider(p_config, self.registry))
133133
elif p_config.type == "mcp":
134-
mcp_client = MCPClient(p_config, self.registry)
135-
await mcp_client.connect()
136-
self.mcp_clients.append(mcp_client)
134+
# Hybrid Support: Convert legacy ProviderConfig to MCPServerConfig
135+
if p_config.base_url and p_config.base_url.startswith("stdio://"):
136+
cmd_str = p_config.base_url[len("stdio://"):]
137+
parts = cmd_str.split()
138+
if parts:
139+
from plexir.core.config_manager import MCPServerConfig
140+
# Naive split: command is parts[0], args are parts[1:]
141+
legacy_mcp_config = MCPServerConfig(
142+
command=parts[0],
143+
args=parts[1:],
144+
env={}
145+
)
146+
mcp_client = MCPClient(p_config.name, legacy_mcp_config, self.registry)
147+
await mcp_client.connect()
148+
self.mcp_clients.append(mcp_client)
149+
else:
150+
logger.warning(f"Legacy MCP provider '{p_config.name}' ignored: Invalid base_url (must start with stdio://).")
137151
except Exception as e:
138152
logger.error(f"Failed to load provider {name}: {e}")
139153

154+
# Load MCP Servers
155+
for name, mcp_config in config_manager.config.mcp_servers.items():
156+
if mcp_config.disabled:
157+
continue
158+
try:
159+
mcp_client = MCPClient(name, mcp_config, self.registry)
160+
# We start connection in background or await it?
161+
# Ideally await to ensure tools are ready before first turn.
162+
await mcp_client.connect()
163+
self.mcp_clients.append(mcp_client)
164+
except Exception as e:
165+
logger.error(f"Failed to load MCP server '{name}': {e}")
166+
140167
async def route(
141168
self,
142169
history: List[Dict[str, Any]],

plexir/mcp/client.py

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing import Dict, Any, List, Optional, Union
1111
from subprocess import SubprocessError
1212

13-
from plexir.core.config_manager import config_manager, ProviderConfig
13+
from plexir.core.config_manager import config_manager, MCPServerConfig
1414
from plexir.tools.base import Tool, ToolRegistry
1515
from pydantic import BaseModel, Field, ValidationError
1616

@@ -28,7 +28,8 @@ class MCPClient:
2828
A robust JSON-RPC 2.0 client for the Model Context Protocol.
2929
Supports stdio transport, lifecycle management, tools, and resources.
3030
"""
31-
def __init__(self, config: ProviderConfig, tool_registry: ToolRegistry):
31+
def __init__(self, name: str, config: MCPServerConfig, tool_registry: ToolRegistry):
32+
self.name = name
3233
self.config = config
3334
self.tool_registry = tool_registry
3435
self.process = None
@@ -40,31 +41,32 @@ def __init__(self, config: ProviderConfig, tool_registry: ToolRegistry):
4041
self.resources: List[Dict[str, Any]] = []
4142
self.resource_templates: List[Dict[str, Any]] = []
4243
self.prompts: List[Dict[str, Any]] = []
43-
logger.info(f"MCP Client initialized for {config.name}.")
44+
logger.info(f"MCP Client initialized for {name}.")
4445

4546
async def connect(self):
4647
"""Establishes connection and performs MCP handshake."""
47-
if not self.config.base_url or not self.config.base_url.startswith("stdio://"):
48-
logger.warning(f"MCP Client {self.config.name}: Invalid/Missing stdio URL.")
49-
return
48+
cmd = self.config.command
49+
args = self.config.args
50+
env_vars = os.environ.copy()
51+
env_vars.update(self.config.env)
5052

51-
command_str = self.config.base_url[len("stdio://"):]
52-
logger.info(f"Starting MCP server: {command_str}")
53+
logger.info(f"Starting MCP server '{self.name}': {cmd} {args}")
5354

5455
try:
5556
self.process = await asyncio.create_subprocess_exec(
56-
*command_str.split(),
57+
cmd, *args,
5758
stdin=asyncio.subprocess.PIPE,
5859
stdout=asyncio.subprocess.PIPE,
59-
stderr=asyncio.subprocess.PIPE
60+
stderr=asyncio.subprocess.PIPE,
61+
env=env_vars
6062
)
6163
self.running = True
6264
self._read_task = asyncio.create_task(self._read_loop())
6365
self._stderr_task = asyncio.create_task(self._read_stderr())
6466

6567
# --- MCP Handshake ---
6668
# 1. Initialize
67-
logger.info(f"MCP {self.config.name}: Sending initialize...")
69+
logger.info(f"MCP {self.name}: Sending initialize...")
6870
init_result = await self.send_request("initialize", {
6971
"protocolVersion": "2024-11-05",
7072
"capabilities": {
@@ -75,7 +77,7 @@ async def connect(self):
7577
},
7678
"clientInfo": {"name": "Plexir", "version": "1.5.0"}
7779
})
78-
logger.info(f"MCP {self.config.name} Initialized. Server: {init_result.get('serverInfo', 'Unknown')}")
80+
logger.info(f"MCP {self.name} Initialized. Server: {init_result.get('serverInfo', 'Unknown')}")
7981

8082
# 2. Initialized Notification
8183
await self.send_notification("notifications/initialized")
@@ -88,10 +90,10 @@ async def connect(self):
8890
)
8991

9092
except FileNotFoundError:
91-
logger.error(f"MCP Client {self.config.name} failed: Command not found.")
93+
logger.error(f"MCP Client {self.name} failed: Command '{cmd}' not found.")
9294
await self.disconnect()
9395
except Exception as e:
94-
logger.error(f"MCP Connection failed: {e}")
96+
logger.error(f"MCP Connection for {self.name} failed: {e}")
9597
await self.disconnect()
9698

9799
async def refresh_tools(self):
@@ -100,9 +102,9 @@ async def refresh_tools(self):
100102
result = await self.send_request("tools/list")
101103
tools = result.get("tools", [])
102104
self._register_mcp_tools(tools)
103-
logger.info(f"MCP {self.config.name}: Registered {len(tools)} tools.")
105+
logger.info(f"MCP {self.name}: Registered {len(tools)} tools.")
104106
except Exception as e:
105-
logger.error(f"Failed to list tools for {self.config.name}: {e}")
107+
logger.error(f"Failed to list tools for {self.name}: {e}")
106108

107109
async def refresh_resources(self):
108110
"""Fetches resources and templates from the server."""
@@ -115,9 +117,9 @@ async def refresh_resources(self):
115117

116118
if self.resources or self.resource_templates:
117119
self._register_resource_tool()
118-
logger.info(f"MCP {self.config.name}: Found {len(self.resources)} resources and {len(self.resource_templates)} templates.")
120+
logger.info(f"MCP {self.name}: Found {len(self.resources)} resources and {len(self.resource_templates)} templates.")
119121
except Exception as e:
120-
logger.debug(f"MCP {self.config.name} resources/list failed: {e}")
122+
logger.debug(f"MCP {self.name} resources/list failed: {e}")
121123

122124
async def refresh_prompts(self):
123125
"""Fetches prompts from the server."""
@@ -126,14 +128,14 @@ async def refresh_prompts(self):
126128
self.prompts = result.get("prompts", [])
127129
if self.prompts:
128130
self._register_prompt_tool()
129-
logger.info(f"MCP {self.config.name}: Found {len(self.prompts)} prompts.")
131+
logger.info(f"MCP {self.name}: Found {len(self.prompts)} prompts.")
130132
except Exception as e:
131-
logger.debug(f"MCP {self.config.name} prompts/list failed: {e}")
133+
logger.debug(f"MCP {self.name} prompts/list failed: {e}")
132134

133135
def _register_resource_tool(self):
134136
"""Registers a tool that allows the agent to list and read MCP resources."""
135137
client = self
136-
server_name = self.config.name
138+
server_name = self.name
137139

138140
class MCPResourceSchema(BaseModel):
139141
action: str = Field(..., description="Action: 'list' or 'read'")
@@ -177,7 +179,7 @@ async def run(self, action: str, uri: Optional[str] = None) -> str:
177179
def _register_prompt_tool(self):
178180
"""Registers a tool that allows the agent to list and use MCP prompts."""
179181
client = self
180-
server_name = self.config.name
182+
server_name = self.name
181183

182184
class MCPPromptSchema(BaseModel):
183185
action: str = Field(..., description="Action: 'list' or 'get'")
@@ -261,7 +263,7 @@ async def _write_json(self, data: Dict):
261263
self.process.stdin.write(json_str.encode())
262264
await self.process.stdin.drain()
263265
except Exception as e:
264-
logger.error(f"Write error {self.config.name}: {e}")
266+
logger.error(f"Write error {self.name}: {e}")
265267
raise
266268

267269
async def _read_loop(self):
@@ -279,7 +281,7 @@ async def _read_loop(self):
279281
try:
280282
message = json.loads(line_str)
281283
except json.JSONDecodeError:
282-
logger.warning(f"MCP {self.config.name} Non-JSON output: {line_str}")
284+
logger.warning(f"MCP {self.name} Non-JSON output: {line_str}")
283285
continue
284286

285287
if "id" in message:
@@ -303,18 +305,18 @@ async def _read_loop(self):
303305
# logger.debug(f"MCP Notification: {method}")
304306

305307
except Exception as e:
306-
logger.error(f"Read loop error {self.config.name}: {e}")
308+
logger.error(f"Read loop error {self.name}: {e}")
307309
break
308310

309-
logger.info(f"MCP {self.config.name} read loop ended.")
311+
logger.info(f"MCP {self.name} read loop ended.")
310312
await self.disconnect()
311313

312314
async def _read_stderr(self):
313315
"""Logs stderr from the server process."""
314316
while self.running and self.process.stderr:
315317
line = await self.process.stderr.readline()
316318
if not line: break
317-
logger.warning(f"MCP STDERR [{self.config.name}]: {line.decode().strip()}")
319+
logger.warning(f"MCP STDERR [{self.name}]: {line.decode().strip()}")
318320

319321
def _register_mcp_tools(self, tool_definitions: List[Dict[str, Any]]):
320322
"""Dynamically creates Tool classes for MCP tools."""
@@ -393,4 +395,4 @@ async def disconnect(self):
393395
await self.process.wait()
394396
except Exception:
395397
pass
396-
logger.info(f"MCP Client {self.config.name} disconnected.")
398+
logger.info(f"MCP Client {self.name} disconnected.")

0 commit comments

Comments
 (0)