k-cli-for-devs 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- k_cli/__init__.py +77 -0
- k_cli/agents/__init__.py +0 -0
- k_cli/agents/adversarial_swarm.py +338 -0
- k_cli/agents/agent_core.py +255 -0
- k_cli/agents/background_daemon.py +141 -0
- k_cli/agents/orchestrator.py +376 -0
- k_cli/agents/persona.py +649 -0
- k_cli/agents/scaffold_engine.py +121 -0
- k_cli/agents/strands_agent.py +832 -0
- k_cli/agents/subagents.py +1496 -0
- k_cli/cli.py +3297 -0
- k_cli/core/__init__.py +0 -0
- k_cli/core/airgap.py +95 -0
- k_cli/core/credentials.py +548 -0
- k_cli/core/intent_sensor.py +177 -0
- k_cli/core/llm_driver.py +1028 -0
- k_cli/core/model_manager.py +1109 -0
- k_cli/core/models_hub.py +913 -0
- k_cli/core/prompting.py +41 -0
- k_cli/core/sdk.py +322 -0
- k_cli/core/session.py +826 -0
- k_cli/core/smart_router.py +230 -0
- k_cli/core/storage_manager.py +176 -0
- k_cli/core/viewport_engine.py +117 -0
- k_cli/demo/demo_runner.py +579 -0
- k_cli/git/__init__.py +0 -0
- k_cli/git/ai_bisect.py +208 -0
- k_cli/git/conflict_resolver.py +1039 -0
- k_cli/git/git_guard.py +417 -0
- k_cli/git/patcher.py +1175 -0
- k_cli/git/repo_map.py +1780 -0
- k_cli/git/smart_git.py +928 -0
- k_cli/git/verifier.py +969 -0
- k_cli/github/__init__.py +0 -0
- k_cli/github/dedup_engine.py +787 -0
- k_cli/github/github_client.py +1702 -0
- k_cli/github/github_engine.py +641 -0
- k_cli/github/local_hub.py +209 -0
- k_cli/github/pr_watcher.py +129 -0
- k_cli/github/trending.py +205 -0
- k_cli/tools/__init__.py +0 -0
- k_cli/tools/audit.py +79 -0
- k_cli/tools/chaos_immunity.py +377 -0
- k_cli/tools/codebase_qa.py +106 -0
- k_cli/tools/command_runner.py +256 -0
- k_cli/tools/diagram_generator.py +547 -0
- k_cli/tools/doc_retriever.py +1332 -0
- k_cli/tools/feature.py +105 -0
- k_cli/tools/ghost_daemon.py +122 -0
- k_cli/tools/incident_triage.py +1365 -0
- k_cli/tools/mcp_client.py +1846 -0
- k_cli/tools/repo_gardener.py +142 -0
- k_cli/tools/rules.py +109 -0
- k_cli/tools/security.py +52 -0
- k_cli/tools/security_healer.py +999 -0
- k_cli/tools/synapse_graph.py +155 -0
- k_cli/tui/__init__.py +0 -0
- k_cli/tui/diff_viewer.py +223 -0
- k_cli/tui/tui.py +1145 -0
- k_cli/tui/tui_animations.py +648 -0
- k_cli/tui/tui_app.py +2788 -0
- k_cli/ui/__init__.py +10 -0
- k_cli/ui/simple_repl.py +315 -0
- k_cli/web/__init__.py +7 -0
- k_cli/web/server.py +624 -0
- k_cli/web/static/app.js +830 -0
- k_cli/web/static/index.html +495 -0
- k_cli/web/static/monitor.html +189 -0
- k_cli/web/static/style.css +838 -0
- k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
- k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
- k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
- k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
- k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
- k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1846 @@
|
|
|
1
|
+
"""
|
|
2
|
+
mcp_client.py - Universal Model Context Protocol (MCP) Client & Manager for K-CLI
|
|
3
|
+
Project Bankai Engine
|
|
4
|
+
|
|
5
|
+
Complete implementation of the Model Context Protocol (MCP) client specification (JSON-RPC 2.0).
|
|
6
|
+
Supports:
|
|
7
|
+
1. Standard MCP Transports:
|
|
8
|
+
- StdioClientTransport: Subprocess stdin/stdout communication (Node/npx, Python, native binaries)
|
|
9
|
+
- HttpClientTransport: Direct HTTP POST JSON-RPC 2.0 communication
|
|
10
|
+
- SSEClientTransport: Server-Sent Events (SSE) stream + HTTP POST message endpoint
|
|
11
|
+
2. MCPClient:
|
|
12
|
+
- Lifecycle management (initialize, notifications/initialized, ping, close)
|
|
13
|
+
- Tool discovery & invocation (tools/list, tools/call)
|
|
14
|
+
- Resource access (resources/list, resources/read, resources/templates/list)
|
|
15
|
+
- Prompt templates (prompts/list, prompts/get)
|
|
16
|
+
- Tool schema conversion (OpenAI, Anthropic, Gemini function schemas)
|
|
17
|
+
3. MCPManager:
|
|
18
|
+
- Multi-server orchestrator & configuration loader (.kcli/mcp.json, ~/.kcli/mcp.json)
|
|
19
|
+
- Server lifecycle routing & auto-connect
|
|
20
|
+
- Aggregated tool, resource, and prompt catalogs
|
|
21
|
+
- Namespaced & direct tool invocation
|
|
22
|
+
4. CLI Helper functions & programmatic utilities
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import concurrent.futures
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import shlex
|
|
34
|
+
import shutil
|
|
35
|
+
import sys
|
|
36
|
+
import time
|
|
37
|
+
import urllib.parse
|
|
38
|
+
from dataclasses import asdict, dataclass, field
|
|
39
|
+
from enum import Enum
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Callable, Coroutine, Dict, List, Optional, Set, Tuple, TypeVar, Union
|
|
42
|
+
|
|
43
|
+
import httpx
|
|
44
|
+
|
|
45
|
+
# Configure module-level logger
|
|
46
|
+
logger = logging.getLogger("k_cli.mcp_client")
|
|
47
|
+
|
|
48
|
+
# Protocol Constants
|
|
49
|
+
LATEST_PROTOCOL_VERSION = "2024-11-05"
|
|
50
|
+
SUPPORTED_PROTOCOL_VERSIONS = [
|
|
51
|
+
"2024-11-05",
|
|
52
|
+
"2024-10-07",
|
|
53
|
+
"0.1.0",
|
|
54
|
+
]
|
|
55
|
+
JSONRPC_VERSION = "2.0"
|
|
56
|
+
CLIENT_INFO = {
|
|
57
|
+
"name": "k-cli",
|
|
58
|
+
"version": "0.3.0",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Standard JSON-RPC 2.0 Error Codes
|
|
62
|
+
PARSE_ERROR = -32700
|
|
63
|
+
INVALID_REQUEST = -32600
|
|
64
|
+
METHOD_NOT_FOUND = -32601
|
|
65
|
+
INVALID_PARAMS = -32602
|
|
66
|
+
INTERNAL_ERROR = -32603
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ==============================================================================
|
|
70
|
+
# 0. Sync / Async Compatibility Runner
|
|
71
|
+
# ==============================================================================
|
|
72
|
+
|
|
73
|
+
T = TypeVar("T")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def run_sync(coro: Coroutine[Any, Any, T]) -> T:
|
|
77
|
+
"""
|
|
78
|
+
Executes a coroutine synchronously from both sync threads and active asyncio event loops.
|
|
79
|
+
Prevents 'RuntimeError: This event loop is already running'.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
loop = asyncio.get_running_loop()
|
|
83
|
+
except RuntimeError:
|
|
84
|
+
loop = None
|
|
85
|
+
|
|
86
|
+
if loop and loop.is_running():
|
|
87
|
+
# We are inside an active event loop. Run in a dedicated worker thread.
|
|
88
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
89
|
+
future = pool.submit(lambda: asyncio.run(coro))
|
|
90
|
+
return future.result()
|
|
91
|
+
else:
|
|
92
|
+
return asyncio.run(coro)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ==============================================================================
|
|
96
|
+
# 1. Exceptions
|
|
97
|
+
# ==============================================================================
|
|
98
|
+
|
|
99
|
+
class MCPError(Exception):
|
|
100
|
+
"""Base exception for all Model Context Protocol errors."""
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MCPTransportError(MCPError):
|
|
105
|
+
"""Raised when transport communication fails or disconnects unexpectedly."""
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class MCPProtocolError(MCPError):
|
|
110
|
+
"""Raised when a JSON-RPC 2.0 protocol violation or server error occurs."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, message: str, code: Optional[int] = None, data: Any = None):
|
|
113
|
+
super().__init__(message)
|
|
114
|
+
self.code = code
|
|
115
|
+
self.data = data
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class MCPTimeoutError(MCPError):
|
|
119
|
+
"""Raised when an MCP operation or request times out."""
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class MCPServerNotFoundError(MCPError):
|
|
124
|
+
"""Raised when a requested server is not defined or registered."""
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class MCPToolExecutionError(MCPError):
|
|
129
|
+
"""Raised when tool execution produces an error or fails validation."""
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ==============================================================================
|
|
134
|
+
# 2. Enums and Data Models
|
|
135
|
+
# ==============================================================================
|
|
136
|
+
|
|
137
|
+
class MCPTransportType(str, Enum):
|
|
138
|
+
STDIO = "stdio"
|
|
139
|
+
SSE = "sse"
|
|
140
|
+
HTTP = "http"
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def from_str(cls, val: str) -> "MCPTransportType":
|
|
144
|
+
v = str(val).lower().strip()
|
|
145
|
+
if v in ("sse", "server-sent-events"):
|
|
146
|
+
return cls.SSE
|
|
147
|
+
if v in ("http", "https", "rest", "post"):
|
|
148
|
+
return cls.HTTP
|
|
149
|
+
return cls.STDIO
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MCPServerStatus(str, Enum):
|
|
153
|
+
DISCONNECTED = "disconnected"
|
|
154
|
+
CONNECTING = "connecting"
|
|
155
|
+
CONNECTED = "connected"
|
|
156
|
+
ERROR = "error"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class MCPServerConfig:
|
|
161
|
+
"""Configuration for a single MCP server."""
|
|
162
|
+
name: str
|
|
163
|
+
transport: str = "stdio"
|
|
164
|
+
command: Optional[str] = None
|
|
165
|
+
args: List[str] = field(default_factory=list)
|
|
166
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
167
|
+
cwd: Optional[str] = None
|
|
168
|
+
url: Optional[str] = None
|
|
169
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
170
|
+
disabled: bool = False
|
|
171
|
+
auto_approve: List[str] = field(default_factory=list)
|
|
172
|
+
timeout: float = 30.0
|
|
173
|
+
|
|
174
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
175
|
+
res: Dict[str, Any] = {
|
|
176
|
+
"transport": self.transport,
|
|
177
|
+
"disabled": self.disabled,
|
|
178
|
+
"timeout": self.timeout,
|
|
179
|
+
}
|
|
180
|
+
if self.command:
|
|
181
|
+
res["command"] = self.command
|
|
182
|
+
if self.args:
|
|
183
|
+
res["args"] = self.args
|
|
184
|
+
if self.env:
|
|
185
|
+
res["env"] = self.env
|
|
186
|
+
if self.cwd:
|
|
187
|
+
res["cwd"] = self.cwd
|
|
188
|
+
if self.url:
|
|
189
|
+
res["url"] = self.url
|
|
190
|
+
if self.headers:
|
|
191
|
+
res["headers"] = self.headers
|
|
192
|
+
if self.auto_approve:
|
|
193
|
+
res["auto_approve"] = self.auto_approve
|
|
194
|
+
return res
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def from_dict(cls, name: str, data: Dict[str, Any]) -> "MCPServerConfig":
|
|
198
|
+
transport = str(data.get("transport", "stdio")).lower()
|
|
199
|
+
if "url" in data and "command" not in data:
|
|
200
|
+
if transport not in ("sse", "http"):
|
|
201
|
+
transport = "sse" if "/sse" in str(data.get("url", "")).lower() else "http"
|
|
202
|
+
|
|
203
|
+
args = data.get("args", [])
|
|
204
|
+
if isinstance(args, str):
|
|
205
|
+
args = shlex.split(args)
|
|
206
|
+
elif not isinstance(args, list):
|
|
207
|
+
args = [str(args)]
|
|
208
|
+
|
|
209
|
+
return cls(
|
|
210
|
+
name=name,
|
|
211
|
+
transport=transport,
|
|
212
|
+
command=data.get("command"),
|
|
213
|
+
args=[str(a) for a in args],
|
|
214
|
+
env={str(k): str(v) for k, v in data.get("env", {}).items()},
|
|
215
|
+
cwd=data.get("cwd"),
|
|
216
|
+
url=data.get("url"),
|
|
217
|
+
headers={str(k): str(v) for k, v in data.get("headers", {}).items()},
|
|
218
|
+
disabled=bool(data.get("disabled", False)),
|
|
219
|
+
auto_approve=list(data.get("auto_approve", data.get("autoApprove", []))),
|
|
220
|
+
timeout=float(data.get("timeout", 30.0)),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class MCPTool:
|
|
226
|
+
"""Represents an MCP Tool definition discovered from an MCP server."""
|
|
227
|
+
name: str
|
|
228
|
+
description: str = ""
|
|
229
|
+
inputSchema: Dict[str, Any] = field(default_factory=lambda: {"type": "object", "properties": {}})
|
|
230
|
+
server_name: Optional[str] = None
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def qualified_name(self) -> str:
|
|
234
|
+
"""Returns server-prefixed name if server_name is set, else bare name."""
|
|
235
|
+
if self.server_name:
|
|
236
|
+
return f"{self.server_name}:{self.name}"
|
|
237
|
+
return self.name
|
|
238
|
+
|
|
239
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
240
|
+
res = {
|
|
241
|
+
"name": self.name,
|
|
242
|
+
"description": self.description,
|
|
243
|
+
"inputSchema": self.inputSchema,
|
|
244
|
+
}
|
|
245
|
+
if self.server_name:
|
|
246
|
+
res["server_name"] = self.server_name
|
|
247
|
+
return res
|
|
248
|
+
|
|
249
|
+
@classmethod
|
|
250
|
+
def from_dict(cls, data: Dict[str, Any], server_name: Optional[str] = None) -> "MCPTool":
|
|
251
|
+
return cls(
|
|
252
|
+
name=str(data.get("name", "")),
|
|
253
|
+
description=str(data.get("description", "")),
|
|
254
|
+
inputSchema=data.get("inputSchema", data.get("input_schema", {"type": "object", "properties": {}})),
|
|
255
|
+
server_name=server_name or data.get("server_name"),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def to_openai_tool(self, use_qualified_name: bool = False) -> Dict[str, Any]:
|
|
259
|
+
"""Convert to OpenAI / OpenAI-compatible function calling schema."""
|
|
260
|
+
fn_name = self.qualified_name if use_qualified_name else self.name
|
|
261
|
+
# Sanitize name for OpenAI (letters, numbers, underscores, dashes, up to 64 chars)
|
|
262
|
+
sanitized_name = re.sub(r"[^a-zA-Z0-9_-]", "_", fn_name)[:64]
|
|
263
|
+
return {
|
|
264
|
+
"type": "function",
|
|
265
|
+
"function": {
|
|
266
|
+
"name": sanitized_name,
|
|
267
|
+
"description": self.description or f"MCP tool: {self.name}",
|
|
268
|
+
"parameters": self.inputSchema or {"type": "object", "properties": {}},
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
def to_anthropic_tool(self, use_qualified_name: bool = False) -> Dict[str, Any]:
|
|
273
|
+
"""Convert to Anthropic Claude tool calling schema."""
|
|
274
|
+
fn_name = self.qualified_name if use_qualified_name else self.name
|
|
275
|
+
sanitized_name = re.sub(r"[^a-zA-Z0-9_-]", "_", fn_name)[:64]
|
|
276
|
+
return {
|
|
277
|
+
"name": sanitized_name,
|
|
278
|
+
"description": self.description or f"MCP tool: {self.name}",
|
|
279
|
+
"input_schema": self.inputSchema or {"type": "object", "properties": {}},
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
def to_gemini_tool(self, use_qualified_name: bool = False) -> Dict[str, Any]:
|
|
283
|
+
"""Convert to Google Gemini function declaration schema."""
|
|
284
|
+
fn_name = self.qualified_name if use_qualified_name else self.name
|
|
285
|
+
sanitized_name = re.sub(r"[^a-zA-Z0-9_]", "_", fn_name)[:64]
|
|
286
|
+
return {
|
|
287
|
+
"name": sanitized_name,
|
|
288
|
+
"description": self.description or f"MCP tool: {self.name}",
|
|
289
|
+
"parameters": self.inputSchema or {"type": "object", "properties": {}},
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@dataclass
|
|
294
|
+
class MCPToolResult:
|
|
295
|
+
"""Represents the execution result of calling an MCP Tool."""
|
|
296
|
+
content: List[Dict[str, Any]] = field(default_factory=list)
|
|
297
|
+
is_error: bool = False
|
|
298
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
299
|
+
server_name: Optional[str] = None
|
|
300
|
+
tool_name: Optional[str] = None
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def text(self) -> str:
|
|
304
|
+
"""Extracts and concatenates all text content items."""
|
|
305
|
+
texts: List[str] = []
|
|
306
|
+
for item in self.content:
|
|
307
|
+
if isinstance(item, dict):
|
|
308
|
+
item_type = item.get("type", "text")
|
|
309
|
+
if item_type == "text" and "text" in item:
|
|
310
|
+
texts.append(str(item["text"]))
|
|
311
|
+
elif item_type == "resource" and "resource" in item:
|
|
312
|
+
res_data = item["resource"]
|
|
313
|
+
if isinstance(res_data, dict) and "text" in res_data:
|
|
314
|
+
texts.append(str(res_data["text"]))
|
|
315
|
+
elif "data" in item:
|
|
316
|
+
texts.append(str(item["data"]))
|
|
317
|
+
elif isinstance(item, str):
|
|
318
|
+
texts.append(item)
|
|
319
|
+
return "\n".join(texts)
|
|
320
|
+
|
|
321
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
322
|
+
return {
|
|
323
|
+
"content": self.content,
|
|
324
|
+
"isError": self.is_error,
|
|
325
|
+
"text": self.text,
|
|
326
|
+
"server_name": self.server_name,
|
|
327
|
+
"tool_name": self.tool_name,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
@classmethod
|
|
331
|
+
def from_dict(
|
|
332
|
+
cls,
|
|
333
|
+
data: Dict[str, Any],
|
|
334
|
+
server_name: Optional[str] = None,
|
|
335
|
+
tool_name: Optional[str] = None,
|
|
336
|
+
) -> "MCPToolResult":
|
|
337
|
+
content = data.get("content", [])
|
|
338
|
+
if isinstance(content, str):
|
|
339
|
+
content = [{"type": "text", "text": content}]
|
|
340
|
+
elif not isinstance(content, list):
|
|
341
|
+
content = [{"type": "text", "text": str(content)}]
|
|
342
|
+
|
|
343
|
+
return cls(
|
|
344
|
+
content=content,
|
|
345
|
+
is_error=bool(data.get("isError", data.get("is_error", False))),
|
|
346
|
+
raw=data,
|
|
347
|
+
server_name=server_name,
|
|
348
|
+
tool_name=tool_name,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass
|
|
353
|
+
class MCPResource:
|
|
354
|
+
"""Represents an MCP Resource (document, data blob, file)."""
|
|
355
|
+
uri: str
|
|
356
|
+
name: str = ""
|
|
357
|
+
description: Optional[str] = None
|
|
358
|
+
mime_type: Optional[str] = None
|
|
359
|
+
text: Optional[str] = None
|
|
360
|
+
blob: Optional[str] = None
|
|
361
|
+
server_name: Optional[str] = None
|
|
362
|
+
|
|
363
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
364
|
+
res: Dict[str, Any] = {
|
|
365
|
+
"uri": self.uri,
|
|
366
|
+
"name": self.name,
|
|
367
|
+
}
|
|
368
|
+
if self.description:
|
|
369
|
+
res["description"] = self.description
|
|
370
|
+
if self.mime_type:
|
|
371
|
+
res["mimeType"] = self.mime_type
|
|
372
|
+
if self.text is not None:
|
|
373
|
+
res["text"] = self.text
|
|
374
|
+
if self.blob is not None:
|
|
375
|
+
res["blob"] = self.blob
|
|
376
|
+
if self.server_name:
|
|
377
|
+
res["server_name"] = self.server_name
|
|
378
|
+
return res
|
|
379
|
+
|
|
380
|
+
@classmethod
|
|
381
|
+
def from_dict(cls, data: Dict[str, Any], server_name: Optional[str] = None) -> "MCPResource":
|
|
382
|
+
return cls(
|
|
383
|
+
uri=str(data.get("uri", "")),
|
|
384
|
+
name=str(data.get("name", "")),
|
|
385
|
+
description=data.get("description"),
|
|
386
|
+
mime_type=data.get("mimeType", data.get("mime_type")),
|
|
387
|
+
text=data.get("text"),
|
|
388
|
+
blob=data.get("blob"),
|
|
389
|
+
server_name=server_name or data.get("server_name"),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass
|
|
394
|
+
class MCPResourceTemplate:
|
|
395
|
+
"""Represents an MCP Resource URI Template."""
|
|
396
|
+
uri_template: str
|
|
397
|
+
name: str = ""
|
|
398
|
+
description: Optional[str] = None
|
|
399
|
+
mime_type: Optional[str] = None
|
|
400
|
+
server_name: Optional[str] = None
|
|
401
|
+
|
|
402
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
403
|
+
res: Dict[str, Any] = {
|
|
404
|
+
"uriTemplate": self.uri_template,
|
|
405
|
+
"name": self.name,
|
|
406
|
+
}
|
|
407
|
+
if self.description:
|
|
408
|
+
res["description"] = self.description
|
|
409
|
+
if self.mime_type:
|
|
410
|
+
res["mimeType"] = self.mime_type
|
|
411
|
+
if self.server_name:
|
|
412
|
+
res["server_name"] = self.server_name
|
|
413
|
+
return res
|
|
414
|
+
|
|
415
|
+
@classmethod
|
|
416
|
+
def from_dict(cls, data: Dict[str, Any], server_name: Optional[str] = None) -> "MCPResourceTemplate":
|
|
417
|
+
return cls(
|
|
418
|
+
uri_template=str(data.get("uriTemplate", data.get("uri_template", ""))),
|
|
419
|
+
name=str(data.get("name", "")),
|
|
420
|
+
description=data.get("description"),
|
|
421
|
+
mime_type=data.get("mimeType", data.get("mime_type")),
|
|
422
|
+
server_name=server_name or data.get("server_name"),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@dataclass
|
|
427
|
+
class MCPPromptArgument:
|
|
428
|
+
name: str
|
|
429
|
+
description: Optional[str] = None
|
|
430
|
+
required: bool = False
|
|
431
|
+
|
|
432
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
433
|
+
return {
|
|
434
|
+
"name": self.name,
|
|
435
|
+
"description": self.description,
|
|
436
|
+
"required": self.required,
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
@classmethod
|
|
440
|
+
def from_dict(cls, data: Dict[str, Any]) -> "MCPPromptArgument":
|
|
441
|
+
return cls(
|
|
442
|
+
name=str(data.get("name", "")),
|
|
443
|
+
description=data.get("description"),
|
|
444
|
+
required=bool(data.get("required", False)),
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@dataclass
|
|
449
|
+
class MCPPrompt:
|
|
450
|
+
name: str
|
|
451
|
+
description: Optional[str] = None
|
|
452
|
+
arguments: List[MCPPromptArgument] = field(default_factory=list)
|
|
453
|
+
server_name: Optional[str] = None
|
|
454
|
+
|
|
455
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
456
|
+
res: Dict[str, Any] = {
|
|
457
|
+
"name": self.name,
|
|
458
|
+
"arguments": [arg.to_dict() for arg in self.arguments],
|
|
459
|
+
}
|
|
460
|
+
if self.description:
|
|
461
|
+
res["description"] = self.description
|
|
462
|
+
if self.server_name:
|
|
463
|
+
res["server_name"] = self.server_name
|
|
464
|
+
return res
|
|
465
|
+
|
|
466
|
+
@classmethod
|
|
467
|
+
def from_dict(cls, data: Dict[str, Any], server_name: Optional[str] = None) -> "MCPPrompt":
|
|
468
|
+
args = [MCPPromptArgument.from_dict(a) for a in data.get("arguments", [])]
|
|
469
|
+
return cls(
|
|
470
|
+
name=str(data.get("name", "")),
|
|
471
|
+
description=data.get("description"),
|
|
472
|
+
arguments=args,
|
|
473
|
+
server_name=server_name or data.get("server_name"),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@dataclass
|
|
478
|
+
class MCPPromptMessage:
|
|
479
|
+
role: str
|
|
480
|
+
content: Union[str, Dict[str, Any], List[Dict[str, Any]]]
|
|
481
|
+
|
|
482
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
483
|
+
return {
|
|
484
|
+
"role": self.role,
|
|
485
|
+
"content": self.content,
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
@classmethod
|
|
489
|
+
def from_dict(cls, data: Dict[str, Any]) -> "MCPPromptMessage":
|
|
490
|
+
return cls(
|
|
491
|
+
role=str(data.get("role", "user")),
|
|
492
|
+
content=data.get("content", ""),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass
|
|
497
|
+
class MCPPromptResult:
|
|
498
|
+
description: Optional[str] = None
|
|
499
|
+
messages: List[MCPPromptMessage] = field(default_factory=list)
|
|
500
|
+
|
|
501
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
502
|
+
return {
|
|
503
|
+
"description": self.description,
|
|
504
|
+
"messages": [m.to_dict() for m in self.messages],
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
@classmethod
|
|
508
|
+
def from_dict(cls, data: Dict[str, Any]) -> "MCPPromptResult":
|
|
509
|
+
messages = [MCPPromptMessage.from_dict(m) for m in data.get("messages", [])]
|
|
510
|
+
return cls(
|
|
511
|
+
description=data.get("description"),
|
|
512
|
+
messages=messages,
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
# ==============================================================================
|
|
517
|
+
# 3. Transports
|
|
518
|
+
# ==============================================================================
|
|
519
|
+
|
|
520
|
+
class BaseClientTransport:
|
|
521
|
+
"""Abstract Base Class for MCP Client Transports."""
|
|
522
|
+
|
|
523
|
+
async def start(self) -> None:
|
|
524
|
+
raise NotImplementedError
|
|
525
|
+
|
|
526
|
+
async def close(self) -> None:
|
|
527
|
+
raise NotImplementedError
|
|
528
|
+
|
|
529
|
+
async def send_message(self, message: Dict[str, Any]) -> None:
|
|
530
|
+
raise NotImplementedError
|
|
531
|
+
|
|
532
|
+
def is_connected(self) -> bool:
|
|
533
|
+
raise NotImplementedError
|
|
534
|
+
|
|
535
|
+
def set_message_handler(self, handler: Callable[[Dict[str, Any]], Coroutine[Any, Any, None]]) -> None:
|
|
536
|
+
self._message_handler = handler
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class StdioClientTransport(BaseClientTransport):
|
|
540
|
+
"""
|
|
541
|
+
Subprocess Stdio Transport for MCP Client.
|
|
542
|
+
Communicates via newline-delimited JSON-RPC messages over stdin/stdout.
|
|
543
|
+
Captures stderr in a buffer for debugging.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
def __init__(
|
|
547
|
+
self,
|
|
548
|
+
command: str,
|
|
549
|
+
args: Optional[List[str]] = None,
|
|
550
|
+
env: Optional[Dict[str, str]] = None,
|
|
551
|
+
cwd: Optional[str] = None,
|
|
552
|
+
):
|
|
553
|
+
self.command = command
|
|
554
|
+
self.args = args or []
|
|
555
|
+
self.env = env or {}
|
|
556
|
+
self.cwd = cwd
|
|
557
|
+
self.process: Optional[asyncio.subprocess.Process] = None
|
|
558
|
+
self._reader_task: Optional[asyncio.Task] = None
|
|
559
|
+
self._stderr_task: Optional[asyncio.Task] = None
|
|
560
|
+
self._message_handler: Optional[Callable[[Dict[str, Any]], Coroutine[Any, Any, None]]] = None
|
|
561
|
+
self._is_closing = False
|
|
562
|
+
self.stderr_log: List[str] = []
|
|
563
|
+
|
|
564
|
+
def is_connected(self) -> bool:
|
|
565
|
+
return self.process is not None and self.process.returncode is None and not self._is_closing
|
|
566
|
+
|
|
567
|
+
async def start(self) -> None:
|
|
568
|
+
if self.is_connected():
|
|
569
|
+
return
|
|
570
|
+
|
|
571
|
+
cmd_path = shutil.which(self.command) or self.command
|
|
572
|
+
full_env = os.environ.copy()
|
|
573
|
+
full_env.update(self.env)
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
self.process = await asyncio.create_subprocess_exec(
|
|
577
|
+
cmd_path,
|
|
578
|
+
*self.args,
|
|
579
|
+
stdin=asyncio.subprocess.PIPE,
|
|
580
|
+
stdout=asyncio.subprocess.PIPE,
|
|
581
|
+
stderr=asyncio.subprocess.PIPE,
|
|
582
|
+
env=full_env,
|
|
583
|
+
cwd=self.cwd,
|
|
584
|
+
)
|
|
585
|
+
except Exception as e:
|
|
586
|
+
raise MCPTransportError(f"Failed to launch stdio subprocess '{self.command}': {e}") from e
|
|
587
|
+
|
|
588
|
+
self._is_closing = False
|
|
589
|
+
self._reader_task = asyncio.create_task(self._read_stdout_loop())
|
|
590
|
+
self._stderr_task = asyncio.create_task(self._read_stderr_loop())
|
|
591
|
+
|
|
592
|
+
async def _read_stdout_loop(self) -> None:
|
|
593
|
+
if not self.process or not self.process.stdout:
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
while not self._is_closing:
|
|
597
|
+
try:
|
|
598
|
+
line_bytes = await self.process.stdout.readline()
|
|
599
|
+
if not line_bytes:
|
|
600
|
+
break # EOF
|
|
601
|
+
|
|
602
|
+
line_str = line_bytes.decode("utf-8", errors="replace").strip()
|
|
603
|
+
if not line_str:
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
msg = json.loads(line_str)
|
|
608
|
+
except json.JSONDecodeError as jde:
|
|
609
|
+
logger.debug("Failed to decode JSON line from stdio: %s (%s)", line_str, jde)
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
if self._message_handler and isinstance(msg, dict):
|
|
613
|
+
asyncio.create_task(self._message_handler(msg))
|
|
614
|
+
except asyncio.CancelledError:
|
|
615
|
+
break
|
|
616
|
+
except Exception as e:
|
|
617
|
+
logger.error("Error in stdio stdout reader loop: %s", e)
|
|
618
|
+
break
|
|
619
|
+
|
|
620
|
+
async def _read_stderr_loop(self) -> None:
|
|
621
|
+
if not self.process or not self.process.stderr:
|
|
622
|
+
return
|
|
623
|
+
|
|
624
|
+
while not self._is_closing:
|
|
625
|
+
try:
|
|
626
|
+
line_bytes = await self.process.stderr.readline()
|
|
627
|
+
if not line_bytes:
|
|
628
|
+
break
|
|
629
|
+
line_str = line_bytes.decode("utf-8", errors="replace").rstrip()
|
|
630
|
+
if line_str:
|
|
631
|
+
self.stderr_log.append(line_str)
|
|
632
|
+
if len(self.stderr_log) > 200:
|
|
633
|
+
self.stderr_log.pop(0)
|
|
634
|
+
logger.debug("[MCP stdio stderr] %s", line_str)
|
|
635
|
+
except asyncio.CancelledError:
|
|
636
|
+
break
|
|
637
|
+
except Exception:
|
|
638
|
+
break
|
|
639
|
+
|
|
640
|
+
async def send_message(self, message: Dict[str, Any]) -> None:
|
|
641
|
+
if not self.is_connected() or not self.process or not self.process.stdin:
|
|
642
|
+
raise MCPTransportError("Stdio transport is not connected.")
|
|
643
|
+
|
|
644
|
+
try:
|
|
645
|
+
line = json.dumps(message) + "\n"
|
|
646
|
+
self.process.stdin.write(line.encode("utf-8"))
|
|
647
|
+
await self.process.stdin.drain()
|
|
648
|
+
except Exception as e:
|
|
649
|
+
raise MCPTransportError(f"Failed to write message to stdio stdin: {e}") from e
|
|
650
|
+
|
|
651
|
+
async def close(self) -> None:
|
|
652
|
+
self._is_closing = True
|
|
653
|
+
if self._reader_task and not self._reader_task.done():
|
|
654
|
+
self._reader_task.cancel()
|
|
655
|
+
if self._stderr_task and not self._stderr_task.done():
|
|
656
|
+
self._stderr_task.cancel()
|
|
657
|
+
|
|
658
|
+
if self.process:
|
|
659
|
+
try:
|
|
660
|
+
if self.process.stdin and not self.process.stdin.is_closing():
|
|
661
|
+
self.process.stdin.close()
|
|
662
|
+
await self.process.stdin.wait_closed()
|
|
663
|
+
except Exception:
|
|
664
|
+
pass
|
|
665
|
+
|
|
666
|
+
try:
|
|
667
|
+
self.process.terminate()
|
|
668
|
+
try:
|
|
669
|
+
await asyncio.wait_for(self.process.wait(), timeout=2.0)
|
|
670
|
+
except asyncio.TimeoutError:
|
|
671
|
+
self.process.kill()
|
|
672
|
+
await self.process.wait()
|
|
673
|
+
except ProcessLookupError:
|
|
674
|
+
pass
|
|
675
|
+
except Exception as e:
|
|
676
|
+
logger.debug("Error while terminating process: %s", e)
|
|
677
|
+
finally:
|
|
678
|
+
self.process = None
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
class HttpClientTransport(BaseClientTransport):
|
|
682
|
+
"""
|
|
683
|
+
Direct HTTP POST Transport for MCP Client.
|
|
684
|
+
Sends JSON-RPC 2.0 requests via standard HTTP POST and parses response JSON.
|
|
685
|
+
"""
|
|
686
|
+
|
|
687
|
+
def __init__(
|
|
688
|
+
self,
|
|
689
|
+
url: str,
|
|
690
|
+
headers: Optional[Dict[str, str]] = None,
|
|
691
|
+
timeout: float = 30.0,
|
|
692
|
+
):
|
|
693
|
+
self.url = url
|
|
694
|
+
self.headers = headers or {}
|
|
695
|
+
self.timeout = timeout
|
|
696
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
697
|
+
self._connected = False
|
|
698
|
+
self._message_handler: Optional[Callable[[Dict[str, Any]], Coroutine[Any, Any, None]]] = None
|
|
699
|
+
|
|
700
|
+
def is_connected(self) -> bool:
|
|
701
|
+
return self._connected and self._client is not None
|
|
702
|
+
|
|
703
|
+
async def start(self) -> None:
|
|
704
|
+
if self.is_connected():
|
|
705
|
+
return
|
|
706
|
+
default_headers = {
|
|
707
|
+
"Content-Type": "application/json",
|
|
708
|
+
"Accept": "application/json",
|
|
709
|
+
"User-Agent": "k-cli/0.3.0 MCPClient",
|
|
710
|
+
}
|
|
711
|
+
default_headers.update(self.headers)
|
|
712
|
+
self._client = httpx.AsyncClient(
|
|
713
|
+
headers=default_headers,
|
|
714
|
+
timeout=httpx.Timeout(self.timeout, connect=10.0),
|
|
715
|
+
)
|
|
716
|
+
self._connected = True
|
|
717
|
+
|
|
718
|
+
async def send_message(self, message: Dict[str, Any]) -> None:
|
|
719
|
+
if not self.is_connected() or not self._client:
|
|
720
|
+
raise MCPTransportError("HTTP transport is not connected.")
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
resp = await self._client.post(self.url, json=message)
|
|
724
|
+
resp.raise_for_status()
|
|
725
|
+
|
|
726
|
+
if resp.content:
|
|
727
|
+
try:
|
|
728
|
+
data = resp.json()
|
|
729
|
+
if self._message_handler and isinstance(data, dict):
|
|
730
|
+
asyncio.create_task(self._message_handler(data))
|
|
731
|
+
except Exception as je:
|
|
732
|
+
logger.debug("HTTP response is not valid JSON: %s", je)
|
|
733
|
+
except Exception as e:
|
|
734
|
+
raise MCPTransportError(f"HTTP POST request failed: {e}") from e
|
|
735
|
+
|
|
736
|
+
async def close(self) -> None:
|
|
737
|
+
self._connected = False
|
|
738
|
+
if self._client:
|
|
739
|
+
await self._client.aclose()
|
|
740
|
+
self._client = None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class SSEClientTransport(BaseClientTransport):
|
|
744
|
+
"""
|
|
745
|
+
Server-Sent Events (SSE) Transport for MCP Client.
|
|
746
|
+
Connects to SSE endpoint via GET to stream events/messages from server.
|
|
747
|
+
Discovers the POST endpoint from the 'endpoint' SSE event to send requests.
|
|
748
|
+
"""
|
|
749
|
+
|
|
750
|
+
def __init__(
|
|
751
|
+
self,
|
|
752
|
+
url: str,
|
|
753
|
+
headers: Optional[Dict[str, str]] = None,
|
|
754
|
+
timeout: float = 30.0,
|
|
755
|
+
):
|
|
756
|
+
self.url = url
|
|
757
|
+
self.headers = headers or {}
|
|
758
|
+
self.timeout = timeout
|
|
759
|
+
self.post_url: Optional[str] = None
|
|
760
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
761
|
+
self._sse_task: Optional[asyncio.Task] = None
|
|
762
|
+
self._connected = False
|
|
763
|
+
self._is_closing = False
|
|
764
|
+
self._endpoint_event = asyncio.Event()
|
|
765
|
+
self._message_handler: Optional[Callable[[Dict[str, Any]], Coroutine[Any, Any, None]]] = None
|
|
766
|
+
|
|
767
|
+
def is_connected(self) -> bool:
|
|
768
|
+
return self._connected and not self._is_closing
|
|
769
|
+
|
|
770
|
+
async def start(self) -> None:
|
|
771
|
+
if self.is_connected():
|
|
772
|
+
return
|
|
773
|
+
|
|
774
|
+
default_headers = {
|
|
775
|
+
"Accept": "text/event-stream",
|
|
776
|
+
"User-Agent": "k-cli/0.3.0 MCPClient-SSE",
|
|
777
|
+
}
|
|
778
|
+
default_headers.update(self.headers)
|
|
779
|
+
self._client = httpx.AsyncClient(
|
|
780
|
+
headers=default_headers,
|
|
781
|
+
timeout=httpx.Timeout(None, connect=10.0),
|
|
782
|
+
)
|
|
783
|
+
self._is_closing = False
|
|
784
|
+
self._endpoint_event.clear()
|
|
785
|
+
self._sse_task = asyncio.create_task(self._stream_sse_loop())
|
|
786
|
+
|
|
787
|
+
# Wait for SSE connection or timeout
|
|
788
|
+
try:
|
|
789
|
+
await asyncio.wait_for(self._endpoint_event.wait(), timeout=min(10.0, self.timeout))
|
|
790
|
+
self._connected = True
|
|
791
|
+
except asyncio.TimeoutError:
|
|
792
|
+
# If server does not send an explicit endpoint event, fallback post_url to base URL
|
|
793
|
+
if not self.post_url:
|
|
794
|
+
self.post_url = self.url
|
|
795
|
+
self._connected = True
|
|
796
|
+
|
|
797
|
+
async def _stream_sse_loop(self) -> None:
|
|
798
|
+
if not self._client:
|
|
799
|
+
return
|
|
800
|
+
|
|
801
|
+
current_event = "message"
|
|
802
|
+
data_buffer: List[str] = []
|
|
803
|
+
|
|
804
|
+
try:
|
|
805
|
+
async with self._client.stream("GET", self.url) as response:
|
|
806
|
+
if response.status_code >= 400:
|
|
807
|
+
logger.error("SSE stream failed with HTTP %d", response.status_code)
|
|
808
|
+
return
|
|
809
|
+
|
|
810
|
+
async for line in response.aiter_lines():
|
|
811
|
+
if self._is_closing:
|
|
812
|
+
break
|
|
813
|
+
|
|
814
|
+
line = line.strip()
|
|
815
|
+
if not line:
|
|
816
|
+
# Empty line signals dispatch of the accumulated event
|
|
817
|
+
if data_buffer:
|
|
818
|
+
full_data = "\n".join(data_buffer)
|
|
819
|
+
data_buffer.clear()
|
|
820
|
+
await self._handle_sse_event(current_event, full_data)
|
|
821
|
+
current_event = "message"
|
|
822
|
+
continue
|
|
823
|
+
|
|
824
|
+
if line.startswith("event:"):
|
|
825
|
+
current_event = line[len("event:"):].strip()
|
|
826
|
+
elif line.startswith("data:"):
|
|
827
|
+
data_buffer.append(line[len("data:"):].strip())
|
|
828
|
+
|
|
829
|
+
# Flush any remainder
|
|
830
|
+
if data_buffer:
|
|
831
|
+
full_data = "\n".join(data_buffer)
|
|
832
|
+
await self._handle_sse_event(current_event, full_data)
|
|
833
|
+
|
|
834
|
+
except asyncio.CancelledError:
|
|
835
|
+
pass
|
|
836
|
+
except Exception as e:
|
|
837
|
+
logger.debug("SSE stream closed or encountered error: %s", e)
|
|
838
|
+
finally:
|
|
839
|
+
self._connected = False
|
|
840
|
+
|
|
841
|
+
async def _handle_sse_event(self, event: str, data: str) -> None:
|
|
842
|
+
if event == "endpoint":
|
|
843
|
+
# Endpoint event provides URL for sending POST requests
|
|
844
|
+
resolved_url = data.strip()
|
|
845
|
+
if resolved_url.startswith("http://") or resolved_url.startswith("https://"):
|
|
846
|
+
self.post_url = resolved_url
|
|
847
|
+
else:
|
|
848
|
+
self.post_url = urllib.parse.urljoin(self.url, resolved_url)
|
|
849
|
+
self._endpoint_event.set()
|
|
850
|
+
elif event == "message":
|
|
851
|
+
self._endpoint_event.set()
|
|
852
|
+
try:
|
|
853
|
+
msg = json.loads(data)
|
|
854
|
+
if self._message_handler and isinstance(msg, dict):
|
|
855
|
+
asyncio.create_task(self._message_handler(msg))
|
|
856
|
+
except Exception as e:
|
|
857
|
+
logger.debug("Failed to decode JSON from SSE message: %s", e)
|
|
858
|
+
|
|
859
|
+
async def send_message(self, message: Dict[str, Any]) -> None:
|
|
860
|
+
if not self.is_connected() or not self._client:
|
|
861
|
+
raise MCPTransportError("SSE Transport is not connected.")
|
|
862
|
+
|
|
863
|
+
target_url = self.post_url or self.url
|
|
864
|
+
headers = {"Content-Type": "application/json"}
|
|
865
|
+
headers.update(self.headers)
|
|
866
|
+
|
|
867
|
+
try:
|
|
868
|
+
resp = await self._client.post(target_url, json=message, headers=headers)
|
|
869
|
+
resp.raise_for_status()
|
|
870
|
+
# If server responds directly with JSON on the POST response
|
|
871
|
+
if resp.content:
|
|
872
|
+
try:
|
|
873
|
+
data = resp.json()
|
|
874
|
+
if self._message_handler and isinstance(data, dict):
|
|
875
|
+
asyncio.create_task(self._message_handler(data))
|
|
876
|
+
except Exception:
|
|
877
|
+
pass
|
|
878
|
+
except Exception as e:
|
|
879
|
+
raise MCPTransportError(f"Failed to post message over SSE transport: {e}") from e
|
|
880
|
+
|
|
881
|
+
async def close(self) -> None:
|
|
882
|
+
self._is_closing = True
|
|
883
|
+
self._connected = False
|
|
884
|
+
if self._sse_task and not self._sse_task.done():
|
|
885
|
+
self._sse_task.cancel()
|
|
886
|
+
if self._client:
|
|
887
|
+
await self._client.aclose()
|
|
888
|
+
self._client = None
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
# ==============================================================================
|
|
892
|
+
# 4. MCPClient
|
|
893
|
+
# ==============================================================================
|
|
894
|
+
|
|
895
|
+
class MCPClient:
|
|
896
|
+
"""
|
|
897
|
+
Model Context Protocol (MCP) Client.
|
|
898
|
+
Manages connection, protocol handshake, tools, resources, and prompts with an MCP server.
|
|
899
|
+
Provides both asynchronous and synchronous execution interfaces.
|
|
900
|
+
"""
|
|
901
|
+
|
|
902
|
+
def __init__(
|
|
903
|
+
self,
|
|
904
|
+
name: str,
|
|
905
|
+
config: MCPServerConfig,
|
|
906
|
+
transport: Optional[BaseClientTransport] = None,
|
|
907
|
+
):
|
|
908
|
+
self.name = name
|
|
909
|
+
self.config = config
|
|
910
|
+
self.transport = transport or self._create_transport(config)
|
|
911
|
+
self.status = MCPServerStatus.DISCONNECTED
|
|
912
|
+
self.server_info: Dict[str, Any] = {}
|
|
913
|
+
self.server_capabilities: Dict[str, Any] = {}
|
|
914
|
+
self.protocol_version: str = LATEST_PROTOCOL_VERSION
|
|
915
|
+
|
|
916
|
+
self._request_id = 0
|
|
917
|
+
self._pending_requests: Dict[Union[int, str], asyncio.Future] = {}
|
|
918
|
+
self._tools_cache: Optional[List[MCPTool]] = None
|
|
919
|
+
self._resources_cache: Optional[List[MCPResource]] = None
|
|
920
|
+
self._prompts_cache: Optional[List[MCPPrompt]] = None
|
|
921
|
+
|
|
922
|
+
@staticmethod
|
|
923
|
+
def _create_transport(config: MCPServerConfig) -> BaseClientTransport:
|
|
924
|
+
transport_type = MCPTransportType.from_str(config.transport)
|
|
925
|
+
if transport_type == MCPTransportType.STDIO:
|
|
926
|
+
if not config.command:
|
|
927
|
+
raise ValueError(f"Server '{config.name}' requires a 'command' for stdio transport.")
|
|
928
|
+
return StdioClientTransport(
|
|
929
|
+
command=config.command,
|
|
930
|
+
args=config.args,
|
|
931
|
+
env=config.env,
|
|
932
|
+
cwd=config.cwd,
|
|
933
|
+
)
|
|
934
|
+
elif transport_type == MCPTransportType.SSE:
|
|
935
|
+
if not config.url:
|
|
936
|
+
raise ValueError(f"Server '{config.name}' requires a 'url' for SSE transport.")
|
|
937
|
+
return SSEClientTransport(
|
|
938
|
+
url=config.url,
|
|
939
|
+
headers=config.headers,
|
|
940
|
+
timeout=config.timeout,
|
|
941
|
+
)
|
|
942
|
+
elif transport_type == MCPTransportType.HTTP:
|
|
943
|
+
if not config.url:
|
|
944
|
+
raise ValueError(f"Server '{config.name}' requires a 'url' for HTTP transport.")
|
|
945
|
+
return HttpClientTransport(
|
|
946
|
+
url=config.url,
|
|
947
|
+
headers=config.headers,
|
|
948
|
+
timeout=config.timeout,
|
|
949
|
+
)
|
|
950
|
+
raise ValueError(f"Unsupported transport '{config.transport}' for server '{config.name}'.")
|
|
951
|
+
|
|
952
|
+
def is_connected(self) -> bool:
|
|
953
|
+
return self.status == MCPServerStatus.CONNECTED and self.transport is not None and self.transport.is_connected()
|
|
954
|
+
|
|
955
|
+
# --------------------------------------------------------------------------
|
|
956
|
+
# Async Connection Lifecycle
|
|
957
|
+
# --------------------------------------------------------------------------
|
|
958
|
+
|
|
959
|
+
async def connect_async(self) -> bool:
|
|
960
|
+
"""Asynchronously connect to the server and perform the initialize handshake."""
|
|
961
|
+
if self.is_connected():
|
|
962
|
+
return True
|
|
963
|
+
|
|
964
|
+
self.status = MCPServerStatus.CONNECTING
|
|
965
|
+
try:
|
|
966
|
+
self.transport.set_message_handler(self._handle_incoming_message)
|
|
967
|
+
await self.transport.start()
|
|
968
|
+
|
|
969
|
+
# Perform initialize handshake
|
|
970
|
+
init_params = {
|
|
971
|
+
"protocolVersion": LATEST_PROTOCOL_VERSION,
|
|
972
|
+
"capabilities": {
|
|
973
|
+
"roots": {"listChanged": True},
|
|
974
|
+
"sampling": {},
|
|
975
|
+
},
|
|
976
|
+
"clientInfo": CLIENT_INFO,
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
init_result = await self.send_request_async("initialize", init_params, timeout=self.config.timeout)
|
|
980
|
+
|
|
981
|
+
self.protocol_version = init_result.get("protocolVersion", LATEST_PROTOCOL_VERSION)
|
|
982
|
+
self.server_info = init_result.get("serverInfo", {})
|
|
983
|
+
self.server_capabilities = init_result.get("capabilities", {})
|
|
984
|
+
|
|
985
|
+
# Send initialized notification
|
|
986
|
+
await self.send_notification_async("notifications/initialized", {})
|
|
987
|
+
|
|
988
|
+
self.status = MCPServerStatus.CONNECTED
|
|
989
|
+
return True
|
|
990
|
+
except Exception as e:
|
|
991
|
+
self.status = MCPServerStatus.ERROR
|
|
992
|
+
await self.disconnect_async()
|
|
993
|
+
logger.error("Failed to connect to MCP server '%s': %s", self.name, e)
|
|
994
|
+
raise MCPTransportError(f"Failed to connect to MCP server '{self.name}': {e}") from e
|
|
995
|
+
|
|
996
|
+
async def disconnect_async(self) -> None:
|
|
997
|
+
"""Asynchronously disconnect and release resources."""
|
|
998
|
+
self.status = MCPServerStatus.DISCONNECTED
|
|
999
|
+
# Fail any pending requests
|
|
1000
|
+
for req_id, fut in list(self._pending_requests.items()):
|
|
1001
|
+
if not fut.done():
|
|
1002
|
+
fut.set_exception(MCPTransportError("Client disconnected."))
|
|
1003
|
+
self._pending_requests.clear()
|
|
1004
|
+
self._tools_cache = None
|
|
1005
|
+
self._resources_cache = None
|
|
1006
|
+
self._prompts_cache = None
|
|
1007
|
+
|
|
1008
|
+
if self.transport:
|
|
1009
|
+
await self.transport.close()
|
|
1010
|
+
|
|
1011
|
+
# --------------------------------------------------------------------------
|
|
1012
|
+
# JSON-RPC 2.0 Request / Notification Engine
|
|
1013
|
+
# --------------------------------------------------------------------------
|
|
1014
|
+
|
|
1015
|
+
async def _handle_incoming_message(self, message: Dict[str, Any]) -> None:
|
|
1016
|
+
"""Dispatches incoming JSON-RPC 2.0 messages (Responses, Errors, Notifications)."""
|
|
1017
|
+
# 1. Response or Error (has 'id')
|
|
1018
|
+
if "id" in message and message["id"] is not None:
|
|
1019
|
+
req_id = message["id"]
|
|
1020
|
+
future = self._pending_requests.pop(req_id, None)
|
|
1021
|
+
if future and not future.done():
|
|
1022
|
+
if "error" in message and message["error"]:
|
|
1023
|
+
err = message["error"]
|
|
1024
|
+
code = err.get("code")
|
|
1025
|
+
msg = err.get("message", "Unknown error")
|
|
1026
|
+
data = err.get("data")
|
|
1027
|
+
future.set_exception(MCPProtocolError(f"MCP Server Error [{code}]: {msg}", code=code, data=data))
|
|
1028
|
+
else:
|
|
1029
|
+
future.set_result(message.get("result", {}))
|
|
1030
|
+
# 2. Server Notification (no 'id')
|
|
1031
|
+
elif "method" in message:
|
|
1032
|
+
method = message["method"]
|
|
1033
|
+
params = message.get("params", {})
|
|
1034
|
+
if method in ("notifications/tools/list_changed", "notifications/resources/list_changed"):
|
|
1035
|
+
self._tools_cache = None
|
|
1036
|
+
self._resources_cache = None
|
|
1037
|
+
logger.debug("Received notification from server '%s': %s", self.name, method)
|
|
1038
|
+
|
|
1039
|
+
async def send_request_async(
|
|
1040
|
+
self,
|
|
1041
|
+
method: str,
|
|
1042
|
+
params: Optional[Dict[str, Any]] = None,
|
|
1043
|
+
timeout: Optional[float] = None,
|
|
1044
|
+
) -> Any:
|
|
1045
|
+
"""Sends a JSON-RPC 2.0 request and awaits the result."""
|
|
1046
|
+
if not self.transport or (not self.transport.is_connected() and method != "initialize"):
|
|
1047
|
+
raise MCPTransportError(f"Cannot send request '{method}'; server '{self.name}' is disconnected.")
|
|
1048
|
+
|
|
1049
|
+
self._request_id += 1
|
|
1050
|
+
req_id = self._request_id
|
|
1051
|
+
loop = asyncio.get_running_loop()
|
|
1052
|
+
future: asyncio.Future = loop.create_future()
|
|
1053
|
+
self._pending_requests[req_id] = future
|
|
1054
|
+
|
|
1055
|
+
payload: Dict[str, Any] = {
|
|
1056
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
1057
|
+
"id": req_id,
|
|
1058
|
+
"method": method,
|
|
1059
|
+
}
|
|
1060
|
+
if params is not None:
|
|
1061
|
+
payload["params"] = params
|
|
1062
|
+
|
|
1063
|
+
effective_timeout = timeout if timeout is not None else self.config.timeout
|
|
1064
|
+
|
|
1065
|
+
try:
|
|
1066
|
+
await self.transport.send_message(payload)
|
|
1067
|
+
result = await asyncio.wait_for(future, timeout=effective_timeout)
|
|
1068
|
+
return result
|
|
1069
|
+
except asyncio.TimeoutError:
|
|
1070
|
+
self._pending_requests.pop(req_id, None)
|
|
1071
|
+
# Send cancellation notification to server
|
|
1072
|
+
try:
|
|
1073
|
+
await self.send_notification_async("notifications/cancelled", {"requestId": req_id, "reason": "timeout"})
|
|
1074
|
+
except Exception:
|
|
1075
|
+
pass
|
|
1076
|
+
raise MCPTimeoutError(f"Request '{method}' (id={req_id}) to server '{self.name}' timed out after {effective_timeout}s.")
|
|
1077
|
+
except Exception:
|
|
1078
|
+
self._pending_requests.pop(req_id, None)
|
|
1079
|
+
raise
|
|
1080
|
+
|
|
1081
|
+
async def send_notification_async(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
1082
|
+
"""Sends a JSON-RPC 2.0 notification (no response expected)."""
|
|
1083
|
+
if not self.transport or not self.transport.is_connected():
|
|
1084
|
+
return
|
|
1085
|
+
|
|
1086
|
+
payload: Dict[str, Any] = {
|
|
1087
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
1088
|
+
"method": method,
|
|
1089
|
+
}
|
|
1090
|
+
if params is not None:
|
|
1091
|
+
payload["params"] = params
|
|
1092
|
+
|
|
1093
|
+
try:
|
|
1094
|
+
await self.transport.send_message(payload)
|
|
1095
|
+
except Exception as e:
|
|
1096
|
+
logger.debug("Failed to send notification '%s' to '%s': %s", method, self.name, e)
|
|
1097
|
+
|
|
1098
|
+
# --------------------------------------------------------------------------
|
|
1099
|
+
# Ping & Health Check
|
|
1100
|
+
# --------------------------------------------------------------------------
|
|
1101
|
+
|
|
1102
|
+
async def ping_async(self) -> bool:
|
|
1103
|
+
"""Sends a ping request to check server liveliness."""
|
|
1104
|
+
try:
|
|
1105
|
+
await self.send_request_async("ping", {}, timeout=5.0)
|
|
1106
|
+
return True
|
|
1107
|
+
except Exception:
|
|
1108
|
+
return False
|
|
1109
|
+
|
|
1110
|
+
# --------------------------------------------------------------------------
|
|
1111
|
+
# Tools API
|
|
1112
|
+
# --------------------------------------------------------------------------
|
|
1113
|
+
|
|
1114
|
+
async def list_tools_async(self, refresh: bool = False) -> List[MCPTool]:
|
|
1115
|
+
"""Discovers tools exposed by the MCP server."""
|
|
1116
|
+
if self._tools_cache is not None and not refresh:
|
|
1117
|
+
return self._tools_cache
|
|
1118
|
+
|
|
1119
|
+
tools: List[MCPTool] = []
|
|
1120
|
+
cursor: Optional[str] = None
|
|
1121
|
+
|
|
1122
|
+
while True:
|
|
1123
|
+
params: Dict[str, Any] = {}
|
|
1124
|
+
if cursor:
|
|
1125
|
+
params["cursor"] = cursor
|
|
1126
|
+
|
|
1127
|
+
result = await self.send_request_async("tools/list", params)
|
|
1128
|
+
raw_tools = result.get("tools", [])
|
|
1129
|
+
for item in raw_tools:
|
|
1130
|
+
tools.append(MCPTool.from_dict(item, server_name=self.name))
|
|
1131
|
+
|
|
1132
|
+
cursor = result.get("nextCursor")
|
|
1133
|
+
if not cursor:
|
|
1134
|
+
break
|
|
1135
|
+
|
|
1136
|
+
self._tools_cache = tools
|
|
1137
|
+
return tools
|
|
1138
|
+
|
|
1139
|
+
async def call_tool_async(self, tool_name: str, arguments: Optional[Dict[str, Any]] = None) -> MCPToolResult:
|
|
1140
|
+
"""Executes a tool on the MCP server."""
|
|
1141
|
+
params = {
|
|
1142
|
+
"name": tool_name,
|
|
1143
|
+
"arguments": arguments or {},
|
|
1144
|
+
}
|
|
1145
|
+
result_data = await self.send_request_async("tools/call", params)
|
|
1146
|
+
return MCPToolResult.from_dict(result_data, server_name=self.name, tool_name=tool_name)
|
|
1147
|
+
|
|
1148
|
+
# --------------------------------------------------------------------------
|
|
1149
|
+
# Resources API
|
|
1150
|
+
# --------------------------------------------------------------------------
|
|
1151
|
+
|
|
1152
|
+
async def list_resources_async(self, refresh: bool = False) -> List[MCPResource]:
|
|
1153
|
+
"""Lists resources published by the MCP server."""
|
|
1154
|
+
if self._resources_cache is not None and not refresh:
|
|
1155
|
+
return self._resources_cache
|
|
1156
|
+
|
|
1157
|
+
resources: List[MCPResource] = []
|
|
1158
|
+
cursor: Optional[str] = None
|
|
1159
|
+
|
|
1160
|
+
while True:
|
|
1161
|
+
params: Dict[str, Any] = {}
|
|
1162
|
+
if cursor:
|
|
1163
|
+
params["cursor"] = cursor
|
|
1164
|
+
|
|
1165
|
+
result = await self.send_request_async("resources/list", params)
|
|
1166
|
+
raw_resources = result.get("resources", [])
|
|
1167
|
+
for item in raw_resources:
|
|
1168
|
+
resources.append(MCPResource.from_dict(item, server_name=self.name))
|
|
1169
|
+
|
|
1170
|
+
cursor = result.get("nextCursor")
|
|
1171
|
+
if not cursor:
|
|
1172
|
+
break
|
|
1173
|
+
|
|
1174
|
+
self._resources_cache = resources
|
|
1175
|
+
return resources
|
|
1176
|
+
|
|
1177
|
+
async def read_resource_async(self, uri: str) -> MCPResource:
|
|
1178
|
+
"""Reads a specific resource URI from the MCP server."""
|
|
1179
|
+
params = {"uri": uri}
|
|
1180
|
+
result = await self.send_request_async("resources/read", params)
|
|
1181
|
+
contents = result.get("contents", [])
|
|
1182
|
+
if not contents:
|
|
1183
|
+
return MCPResource(uri=uri, name=uri, server_name=self.name)
|
|
1184
|
+
|
|
1185
|
+
first = contents[0]
|
|
1186
|
+
return MCPResource(
|
|
1187
|
+
uri=first.get("uri", uri),
|
|
1188
|
+
name=first.get("name", uri.split("/")[-1]),
|
|
1189
|
+
mime_type=first.get("mimeType"),
|
|
1190
|
+
text=first.get("text"),
|
|
1191
|
+
blob=first.get("blob"),
|
|
1192
|
+
server_name=self.name,
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
async def list_resource_templates_async(self) -> List[MCPResourceTemplate]:
|
|
1196
|
+
"""Lists resource URI templates exposed by the server."""
|
|
1197
|
+
try:
|
|
1198
|
+
result = await self.send_request_async("resources/templates/list", {})
|
|
1199
|
+
raw_templates = result.get("resourceTemplates", [])
|
|
1200
|
+
return [MCPResourceTemplate.from_dict(t, server_name=self.name) for t in raw_templates]
|
|
1201
|
+
except Exception:
|
|
1202
|
+
return []
|
|
1203
|
+
|
|
1204
|
+
# --------------------------------------------------------------------------
|
|
1205
|
+
# Prompts API
|
|
1206
|
+
# --------------------------------------------------------------------------
|
|
1207
|
+
|
|
1208
|
+
async def list_prompts_async(self, refresh: bool = False) -> List[MCPPrompt]:
|
|
1209
|
+
"""Lists prompt templates defined on the MCP server."""
|
|
1210
|
+
if self._prompts_cache is not None and not refresh:
|
|
1211
|
+
return self._prompts_cache
|
|
1212
|
+
|
|
1213
|
+
prompts: List[MCPPrompt] = []
|
|
1214
|
+
cursor: Optional[str] = None
|
|
1215
|
+
|
|
1216
|
+
while True:
|
|
1217
|
+
params: Dict[str, Any] = {}
|
|
1218
|
+
if cursor:
|
|
1219
|
+
params["cursor"] = cursor
|
|
1220
|
+
|
|
1221
|
+
result = await self.send_request_async("prompts/list", params)
|
|
1222
|
+
raw_prompts = result.get("prompts", [])
|
|
1223
|
+
for item in raw_prompts:
|
|
1224
|
+
prompts.append(MCPPrompt.from_dict(item, server_name=self.name))
|
|
1225
|
+
|
|
1226
|
+
cursor = result.get("nextCursor")
|
|
1227
|
+
if not cursor:
|
|
1228
|
+
break
|
|
1229
|
+
|
|
1230
|
+
self._prompts_cache = prompts
|
|
1231
|
+
return prompts
|
|
1232
|
+
|
|
1233
|
+
async def get_prompt_async(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> MCPPromptResult:
|
|
1234
|
+
"""Retrieves and fills a prompt template."""
|
|
1235
|
+
params = {
|
|
1236
|
+
"name": name,
|
|
1237
|
+
"arguments": arguments or {},
|
|
1238
|
+
}
|
|
1239
|
+
result = await self.send_request_async("prompts/get", params)
|
|
1240
|
+
return MCPPromptResult.from_dict(result)
|
|
1241
|
+
|
|
1242
|
+
# --------------------------------------------------------------------------
|
|
1243
|
+
# Synchronous Wrappers
|
|
1244
|
+
# --------------------------------------------------------------------------
|
|
1245
|
+
|
|
1246
|
+
def connect(self) -> bool:
|
|
1247
|
+
return run_sync(self.connect_async())
|
|
1248
|
+
|
|
1249
|
+
def disconnect(self) -> None:
|
|
1250
|
+
return run_sync(self.disconnect_async())
|
|
1251
|
+
|
|
1252
|
+
def ping(self) -> bool:
|
|
1253
|
+
return run_sync(self.ping_async())
|
|
1254
|
+
|
|
1255
|
+
def list_tools(self, refresh: bool = False) -> List[MCPTool]:
|
|
1256
|
+
return run_sync(self.list_tools_async(refresh=refresh))
|
|
1257
|
+
|
|
1258
|
+
def call_tool(self, tool_name: str, arguments: Optional[Dict[str, Any]] = None) -> MCPToolResult:
|
|
1259
|
+
return run_sync(self.call_tool_async(tool_name, arguments))
|
|
1260
|
+
|
|
1261
|
+
def list_resources(self, refresh: bool = False) -> List[MCPResource]:
|
|
1262
|
+
return run_sync(self.list_resources_async(refresh=refresh))
|
|
1263
|
+
|
|
1264
|
+
def read_resource(self, uri: str) -> MCPResource:
|
|
1265
|
+
return run_sync(self.read_resource_async(uri))
|
|
1266
|
+
|
|
1267
|
+
def list_prompts(self, refresh: bool = False) -> List[MCPPrompt]:
|
|
1268
|
+
return run_sync(self.list_prompts_async(refresh=refresh))
|
|
1269
|
+
|
|
1270
|
+
def get_prompt(self, name: str, arguments: Optional[Dict[str, Any]] = None) -> MCPPromptResult:
|
|
1271
|
+
return run_sync(self.get_prompt_async(name, arguments))
|
|
1272
|
+
|
|
1273
|
+
# --------------------------------------------------------------------------
|
|
1274
|
+
# Schema Converters
|
|
1275
|
+
# --------------------------------------------------------------------------
|
|
1276
|
+
|
|
1277
|
+
def get_tools_openai_schema(self, use_qualified_names: bool = False) -> List[Dict[str, Any]]:
|
|
1278
|
+
tools = self.list_tools()
|
|
1279
|
+
return [t.to_openai_tool(use_qualified_name=use_qualified_names) for t in tools]
|
|
1280
|
+
|
|
1281
|
+
def get_tools_anthropic_schema(self, use_qualified_names: bool = False) -> List[Dict[str, Any]]:
|
|
1282
|
+
tools = self.list_tools()
|
|
1283
|
+
return [t.to_anthropic_tool(use_qualified_name=use_qualified_names) for t in tools]
|
|
1284
|
+
|
|
1285
|
+
def get_tools_gemini_schema(self, use_qualified_names: bool = False) -> List[Dict[str, Any]]:
|
|
1286
|
+
tools = self.list_tools()
|
|
1287
|
+
return [t.to_gemini_tool(use_qualified_name=use_qualified_names) for t in tools]
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
# ==============================================================================
|
|
1291
|
+
# 5. MCPManager
|
|
1292
|
+
# ==============================================================================
|
|
1293
|
+
|
|
1294
|
+
class MCPManager:
|
|
1295
|
+
"""
|
|
1296
|
+
Central MCP Manager for K-CLI.
|
|
1297
|
+
Handles server discovery from config files (.kcli/mcp.json, ~/.kcli/mcp.json),
|
|
1298
|
+
manages server lifecycles, and routes tool, resource, and prompt requests.
|
|
1299
|
+
"""
|
|
1300
|
+
|
|
1301
|
+
DEFAULT_CONFIG_FILENAMES = [
|
|
1302
|
+
".kcli/mcp.json",
|
|
1303
|
+
"mcp.json",
|
|
1304
|
+
"~/.kcli/mcp.json",
|
|
1305
|
+
"~/.config/kcli/mcp.json",
|
|
1306
|
+
]
|
|
1307
|
+
|
|
1308
|
+
def __init__(self, config_path: Optional[Union[str, Path]] = None, auto_load: bool = True):
|
|
1309
|
+
self.config_path: Optional[Path] = Path(config_path).expanduser().resolve() if config_path else None
|
|
1310
|
+
self.server_configs: Dict[str, MCPServerConfig] = {}
|
|
1311
|
+
self.clients: Dict[str, MCPClient] = {}
|
|
1312
|
+
|
|
1313
|
+
if auto_load:
|
|
1314
|
+
self.load_config(self.config_path)
|
|
1315
|
+
|
|
1316
|
+
# --------------------------------------------------------------------------
|
|
1317
|
+
# Configuration Management
|
|
1318
|
+
# --------------------------------------------------------------------------
|
|
1319
|
+
|
|
1320
|
+
def find_default_config_path(self) -> Optional[Path]:
|
|
1321
|
+
"""Finds the highest priority existing config file."""
|
|
1322
|
+
for candidate in self.DEFAULT_CONFIG_FILENAMES:
|
|
1323
|
+
p = Path(candidate).expanduser()
|
|
1324
|
+
if not p.is_absolute():
|
|
1325
|
+
p = (Path.cwd() / p).resolve()
|
|
1326
|
+
if p.is_file():
|
|
1327
|
+
return p
|
|
1328
|
+
return None
|
|
1329
|
+
|
|
1330
|
+
def load_config(self, config_path: Optional[Union[str, Path]] = None) -> bool:
|
|
1331
|
+
"""
|
|
1332
|
+
Loads server definitions from the specified path or standard search locations.
|
|
1333
|
+
Supports Claude Desktop, Cursor, Antigravity, and K-CLI schema formats.
|
|
1334
|
+
"""
|
|
1335
|
+
target: Optional[Path] = None
|
|
1336
|
+
if config_path:
|
|
1337
|
+
target = Path(config_path).expanduser().resolve()
|
|
1338
|
+
else:
|
|
1339
|
+
target = self.find_default_config_path()
|
|
1340
|
+
|
|
1341
|
+
if not target or not target.is_file():
|
|
1342
|
+
return False
|
|
1343
|
+
|
|
1344
|
+
self.config_path = target
|
|
1345
|
+
try:
|
|
1346
|
+
content = target.read_text(encoding="utf-8")
|
|
1347
|
+
data = json.loads(content)
|
|
1348
|
+
except Exception as e:
|
|
1349
|
+
logger.error("Failed to parse MCP configuration file '%s': %s", target, e)
|
|
1350
|
+
return False
|
|
1351
|
+
|
|
1352
|
+
# Support 'mcpServers', 'servers', or direct dictionary
|
|
1353
|
+
servers_dict = data.get("mcpServers", data.get("servers", data))
|
|
1354
|
+
if not isinstance(servers_dict, dict):
|
|
1355
|
+
return False
|
|
1356
|
+
|
|
1357
|
+
self.server_configs.clear()
|
|
1358
|
+
for name, cfg in servers_dict.items():
|
|
1359
|
+
if isinstance(cfg, dict):
|
|
1360
|
+
try:
|
|
1361
|
+
self.server_configs[name] = MCPServerConfig.from_dict(name, cfg)
|
|
1362
|
+
except Exception as ex:
|
|
1363
|
+
logger.warning("Skipping invalid server config for '%s': %s", name, ex)
|
|
1364
|
+
|
|
1365
|
+
return True
|
|
1366
|
+
|
|
1367
|
+
def save_config(self, config_path: Optional[Union[str, Path]] = None) -> bool:
|
|
1368
|
+
"""Persists current server configurations to disk."""
|
|
1369
|
+
target = Path(config_path).expanduser().resolve() if config_path else self.config_path
|
|
1370
|
+
if not target:
|
|
1371
|
+
target = (Path.cwd() / ".kcli" / "mcp.json").resolve()
|
|
1372
|
+
|
|
1373
|
+
self.config_path = target
|
|
1374
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1375
|
+
|
|
1376
|
+
data = {
|
|
1377
|
+
"mcpServers": {
|
|
1378
|
+
name: cfg.to_dict() for name, cfg in self.server_configs.items()
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
try:
|
|
1383
|
+
target.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
1384
|
+
return True
|
|
1385
|
+
except Exception as e:
|
|
1386
|
+
logger.error("Failed to save MCP configuration to '%s': %s", target, e)
|
|
1387
|
+
return False
|
|
1388
|
+
|
|
1389
|
+
def add_server(
|
|
1390
|
+
self,
|
|
1391
|
+
name: str,
|
|
1392
|
+
config: Union[MCPServerConfig, Dict[str, Any]],
|
|
1393
|
+
save: bool = True,
|
|
1394
|
+
) -> MCPServerConfig:
|
|
1395
|
+
"""Adds or updates a server definition in the manager."""
|
|
1396
|
+
if isinstance(config, dict):
|
|
1397
|
+
cfg_obj = MCPServerConfig.from_dict(name, config)
|
|
1398
|
+
else:
|
|
1399
|
+
cfg_obj = config
|
|
1400
|
+
|
|
1401
|
+
self.server_configs[name] = cfg_obj
|
|
1402
|
+
|
|
1403
|
+
# If a client was already connected under this name, recreate it
|
|
1404
|
+
if name in self.clients:
|
|
1405
|
+
self.disconnect_server(name)
|
|
1406
|
+
|
|
1407
|
+
if save:
|
|
1408
|
+
self.save_config()
|
|
1409
|
+
|
|
1410
|
+
return cfg_obj
|
|
1411
|
+
|
|
1412
|
+
def remove_server(self, name: str, save: bool = True) -> bool:
|
|
1413
|
+
"""Removes a server definition and disconnects its client."""
|
|
1414
|
+
if name in self.clients:
|
|
1415
|
+
self.disconnect_server(name)
|
|
1416
|
+
|
|
1417
|
+
if name in self.server_configs:
|
|
1418
|
+
del self.server_configs[name]
|
|
1419
|
+
if save:
|
|
1420
|
+
self.save_config()
|
|
1421
|
+
return True
|
|
1422
|
+
return False
|
|
1423
|
+
|
|
1424
|
+
def get_server_config(self, name: str) -> Optional[MCPServerConfig]:
|
|
1425
|
+
return self.server_configs.get(name)
|
|
1426
|
+
|
|
1427
|
+
# --------------------------------------------------------------------------
|
|
1428
|
+
# Client Lifecycle Management
|
|
1429
|
+
# --------------------------------------------------------------------------
|
|
1430
|
+
|
|
1431
|
+
def get_client(self, name: str, auto_create: bool = True) -> Optional[MCPClient]:
|
|
1432
|
+
"""Retrieves or creates an MCPClient for the given server name."""
|
|
1433
|
+
if name in self.clients:
|
|
1434
|
+
return self.clients[name]
|
|
1435
|
+
|
|
1436
|
+
if not auto_create:
|
|
1437
|
+
return None
|
|
1438
|
+
|
|
1439
|
+
cfg = self.server_configs.get(name)
|
|
1440
|
+
if not cfg:
|
|
1441
|
+
return None
|
|
1442
|
+
|
|
1443
|
+
client = MCPClient(name=name, config=cfg)
|
|
1444
|
+
self.clients[name] = client
|
|
1445
|
+
return client
|
|
1446
|
+
|
|
1447
|
+
async def connect_server_async(self, name: str, config: Optional[MCPServerConfig] = None) -> MCPClient:
|
|
1448
|
+
"""Asynchronously connects to an MCP server by name."""
|
|
1449
|
+
if config:
|
|
1450
|
+
self.add_server(name, config, save=False)
|
|
1451
|
+
|
|
1452
|
+
client = self.get_client(name)
|
|
1453
|
+
if not client:
|
|
1454
|
+
raise MCPServerNotFoundError(f"Server '{name}' is not configured.")
|
|
1455
|
+
|
|
1456
|
+
if not client.is_connected():
|
|
1457
|
+
await client.connect_async()
|
|
1458
|
+
|
|
1459
|
+
return client
|
|
1460
|
+
|
|
1461
|
+
def connect_server(self, name: str, config: Optional[MCPServerConfig] = None) -> MCPClient:
|
|
1462
|
+
return run_sync(self.connect_server_async(name, config))
|
|
1463
|
+
|
|
1464
|
+
async def disconnect_server_async(self, name: str) -> None:
|
|
1465
|
+
"""Asynchronously disconnects a named MCP server."""
|
|
1466
|
+
if name in self.clients:
|
|
1467
|
+
client = self.clients[name]
|
|
1468
|
+
await client.disconnect_async()
|
|
1469
|
+
del self.clients[name]
|
|
1470
|
+
|
|
1471
|
+
def disconnect_server(self, name: str) -> None:
|
|
1472
|
+
return run_sync(self.disconnect_server_async(name))
|
|
1473
|
+
|
|
1474
|
+
async def connect_all_async(self) -> Dict[str, bool]:
|
|
1475
|
+
"""Asynchronously connects to all non-disabled configured servers."""
|
|
1476
|
+
results: Dict[str, bool] = {}
|
|
1477
|
+
for name, cfg in self.server_configs.items():
|
|
1478
|
+
if cfg.disabled:
|
|
1479
|
+
continue
|
|
1480
|
+
try:
|
|
1481
|
+
client = await self.connect_server_async(name)
|
|
1482
|
+
results[name] = client.is_connected()
|
|
1483
|
+
except Exception as e:
|
|
1484
|
+
logger.error("Failed to connect server '%s': %s", name, e)
|
|
1485
|
+
results[name] = False
|
|
1486
|
+
return results
|
|
1487
|
+
|
|
1488
|
+
def connect_all(self) -> Dict[str, bool]:
|
|
1489
|
+
return run_sync(self.connect_all_async())
|
|
1490
|
+
|
|
1491
|
+
async def disconnect_all_async(self) -> None:
|
|
1492
|
+
"""Asynchronously disconnects all active MCP clients."""
|
|
1493
|
+
for name in list(self.clients.keys()):
|
|
1494
|
+
await self.disconnect_server_async(name)
|
|
1495
|
+
|
|
1496
|
+
def disconnect_all(self) -> None:
|
|
1497
|
+
return run_sync(self.disconnect_all_async())
|
|
1498
|
+
|
|
1499
|
+
def list_servers(self) -> List[Dict[str, Any]]:
|
|
1500
|
+
"""Returns structured status information for all registered MCP servers."""
|
|
1501
|
+
out: List[Dict[str, Any]] = []
|
|
1502
|
+
for name, cfg in self.server_configs.items():
|
|
1503
|
+
client = self.clients.get(name)
|
|
1504
|
+
is_conn = client.is_connected() if client else False
|
|
1505
|
+
status = client.status.value if client else ("disabled" if cfg.disabled else "disconnected")
|
|
1506
|
+
|
|
1507
|
+
tool_count = len(client._tools_cache) if client and client._tools_cache is not None else 0
|
|
1508
|
+
resource_count = len(client._resources_cache) if client and client._resources_cache is not None else 0
|
|
1509
|
+
prompt_count = len(client._prompts_cache) if client and client._prompts_cache is not None else 0
|
|
1510
|
+
|
|
1511
|
+
out.append({
|
|
1512
|
+
"name": name,
|
|
1513
|
+
"transport": cfg.transport,
|
|
1514
|
+
"command": cfg.command or cfg.url or "",
|
|
1515
|
+
"status": status,
|
|
1516
|
+
"connected": is_conn,
|
|
1517
|
+
"disabled": cfg.disabled,
|
|
1518
|
+
"tool_count": tool_count,
|
|
1519
|
+
"resource_count": resource_count,
|
|
1520
|
+
"prompt_count": prompt_count,
|
|
1521
|
+
})
|
|
1522
|
+
return out
|
|
1523
|
+
|
|
1524
|
+
# --------------------------------------------------------------------------
|
|
1525
|
+
# Aggregated Tool Discovery & Execution
|
|
1526
|
+
# --------------------------------------------------------------------------
|
|
1527
|
+
|
|
1528
|
+
async def list_tools_async(self, server_name: Optional[str] = None) -> List[MCPTool]:
|
|
1529
|
+
"""Lists tools across all connected servers or for a specific server."""
|
|
1530
|
+
if server_name:
|
|
1531
|
+
client = await self.connect_server_async(server_name)
|
|
1532
|
+
return await client.list_tools_async()
|
|
1533
|
+
|
|
1534
|
+
all_tools: List[MCPTool] = []
|
|
1535
|
+
for name in list(self.server_configs.keys()):
|
|
1536
|
+
if self.server_configs[name].disabled:
|
|
1537
|
+
continue
|
|
1538
|
+
try:
|
|
1539
|
+
client = await self.connect_server_async(name)
|
|
1540
|
+
tools = await client.list_tools_async()
|
|
1541
|
+
all_tools.extend(tools)
|
|
1542
|
+
except Exception as e:
|
|
1543
|
+
logger.debug("Failed to list tools for '%s': %s", name, e)
|
|
1544
|
+
|
|
1545
|
+
return all_tools
|
|
1546
|
+
|
|
1547
|
+
def list_tools(self, server_name: Optional[str] = None) -> List[MCPTool]:
|
|
1548
|
+
return run_sync(self.list_tools_async(server_name))
|
|
1549
|
+
|
|
1550
|
+
async def call_tool_async(
|
|
1551
|
+
self,
|
|
1552
|
+
tool_name: str,
|
|
1553
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
1554
|
+
server_name: Optional[str] = None,
|
|
1555
|
+
) -> MCPToolResult:
|
|
1556
|
+
"""
|
|
1557
|
+
Executes a tool.
|
|
1558
|
+
Supports qualified names like 'server_name:tool_name' or 'server_name__tool_name'.
|
|
1559
|
+
If server_name is not provided, searches all connected or configured servers.
|
|
1560
|
+
"""
|
|
1561
|
+
arguments = arguments or {}
|
|
1562
|
+
target_server = server_name
|
|
1563
|
+
target_tool = tool_name
|
|
1564
|
+
|
|
1565
|
+
# Parse namespace delimiters
|
|
1566
|
+
if ":" in tool_name:
|
|
1567
|
+
target_server, target_tool = tool_name.split(":", 1)
|
|
1568
|
+
elif "__" in tool_name and not server_name:
|
|
1569
|
+
possible_server, possible_tool = tool_name.split("__", 1)
|
|
1570
|
+
if possible_server in self.server_configs:
|
|
1571
|
+
target_server = possible_server
|
|
1572
|
+
target_tool = possible_tool
|
|
1573
|
+
|
|
1574
|
+
if target_server:
|
|
1575
|
+
client = await self.connect_server_async(target_server)
|
|
1576
|
+
return await client.call_tool_async(target_tool, arguments)
|
|
1577
|
+
|
|
1578
|
+
# Search across all servers for matching tool
|
|
1579
|
+
for s_name in self.server_configs.keys():
|
|
1580
|
+
if self.server_configs[s_name].disabled:
|
|
1581
|
+
continue
|
|
1582
|
+
try:
|
|
1583
|
+
client = await self.connect_server_async(s_name)
|
|
1584
|
+
tools = await client.list_tools_async()
|
|
1585
|
+
for t in tools:
|
|
1586
|
+
if t.name == target_tool:
|
|
1587
|
+
return await client.call_tool_async(target_tool, arguments)
|
|
1588
|
+
except Exception:
|
|
1589
|
+
continue
|
|
1590
|
+
|
|
1591
|
+
raise MCPToolExecutionError(f"Tool '{tool_name}' not found on any active MCP server.")
|
|
1592
|
+
|
|
1593
|
+
def call_tool(
|
|
1594
|
+
self,
|
|
1595
|
+
tool_name: str,
|
|
1596
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
1597
|
+
server_name: Optional[str] = None,
|
|
1598
|
+
) -> MCPToolResult:
|
|
1599
|
+
return run_sync(self.call_tool_async(tool_name, arguments, server_name))
|
|
1600
|
+
|
|
1601
|
+
# --------------------------------------------------------------------------
|
|
1602
|
+
# Aggregated Resource & Prompt APIs
|
|
1603
|
+
# --------------------------------------------------------------------------
|
|
1604
|
+
|
|
1605
|
+
async def list_resources_async(self, server_name: Optional[str] = None) -> List[MCPResource]:
|
|
1606
|
+
if server_name:
|
|
1607
|
+
client = await self.connect_server_async(server_name)
|
|
1608
|
+
return await client.list_resources_async()
|
|
1609
|
+
|
|
1610
|
+
all_res: List[MCPResource] = []
|
|
1611
|
+
for name in self.server_configs.keys():
|
|
1612
|
+
if self.server_configs[name].disabled:
|
|
1613
|
+
continue
|
|
1614
|
+
try:
|
|
1615
|
+
client = await self.connect_server_async(name)
|
|
1616
|
+
res = await client.list_resources_async()
|
|
1617
|
+
all_res.extend(res)
|
|
1618
|
+
except Exception:
|
|
1619
|
+
continue
|
|
1620
|
+
return all_res
|
|
1621
|
+
|
|
1622
|
+
def list_resources(self, server_name: Optional[str] = None) -> List[MCPResource]:
|
|
1623
|
+
return run_sync(self.list_resources_async(server_name))
|
|
1624
|
+
|
|
1625
|
+
async def read_resource_async(self, uri: str, server_name: Optional[str] = None) -> MCPResource:
|
|
1626
|
+
if server_name:
|
|
1627
|
+
client = await self.connect_server_async(server_name)
|
|
1628
|
+
return await client.read_resource_async(uri)
|
|
1629
|
+
|
|
1630
|
+
# Try active clients first
|
|
1631
|
+
for client in self.clients.values():
|
|
1632
|
+
if client.is_connected():
|
|
1633
|
+
try:
|
|
1634
|
+
return await client.read_resource_async(uri)
|
|
1635
|
+
except Exception:
|
|
1636
|
+
continue
|
|
1637
|
+
|
|
1638
|
+
# Fallback to searching all configured servers
|
|
1639
|
+
for name in self.server_configs.keys():
|
|
1640
|
+
try:
|
|
1641
|
+
client = await self.connect_server_async(name)
|
|
1642
|
+
return await client.read_resource_async(uri)
|
|
1643
|
+
except Exception:
|
|
1644
|
+
continue
|
|
1645
|
+
|
|
1646
|
+
raise MCPError(f"Resource '{uri}' could not be read from any MCP server.")
|
|
1647
|
+
|
|
1648
|
+
def read_resource(self, uri: str, server_name: Optional[str] = None) -> MCPResource:
|
|
1649
|
+
return run_sync(self.read_resource_async(uri, server_name))
|
|
1650
|
+
|
|
1651
|
+
async def list_prompts_async(self, server_name: Optional[str] = None) -> List[MCPPrompt]:
|
|
1652
|
+
if server_name:
|
|
1653
|
+
client = await self.connect_server_async(server_name)
|
|
1654
|
+
return await client.list_prompts_async()
|
|
1655
|
+
|
|
1656
|
+
all_prompts: List[MCPPrompt] = []
|
|
1657
|
+
for name in self.server_configs.keys():
|
|
1658
|
+
if self.server_configs[name].disabled:
|
|
1659
|
+
continue
|
|
1660
|
+
try:
|
|
1661
|
+
client = await self.connect_server_async(name)
|
|
1662
|
+
p = await client.list_prompts_async()
|
|
1663
|
+
all_prompts.extend(p)
|
|
1664
|
+
except Exception:
|
|
1665
|
+
continue
|
|
1666
|
+
return all_prompts
|
|
1667
|
+
|
|
1668
|
+
def list_prompts(self, server_name: Optional[str] = None) -> List[MCPPrompt]:
|
|
1669
|
+
return run_sync(self.list_prompts_async(server_name))
|
|
1670
|
+
|
|
1671
|
+
async def get_prompt_async(
|
|
1672
|
+
self,
|
|
1673
|
+
name: str,
|
|
1674
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
1675
|
+
server_name: Optional[str] = None,
|
|
1676
|
+
) -> MCPPromptResult:
|
|
1677
|
+
if server_name:
|
|
1678
|
+
client = await self.connect_server_async(server_name)
|
|
1679
|
+
return await client.get_prompt_async(name, arguments)
|
|
1680
|
+
|
|
1681
|
+
for s_name in self.server_configs.keys():
|
|
1682
|
+
try:
|
|
1683
|
+
client = await self.connect_server_async(s_name)
|
|
1684
|
+
return await client.get_prompt_async(name, arguments)
|
|
1685
|
+
except Exception:
|
|
1686
|
+
continue
|
|
1687
|
+
|
|
1688
|
+
raise MCPError(f"Prompt '{name}' could not be found on any MCP server.")
|
|
1689
|
+
|
|
1690
|
+
def get_prompt(
|
|
1691
|
+
self,
|
|
1692
|
+
name: str,
|
|
1693
|
+
arguments: Optional[Dict[str, Any]] = None,
|
|
1694
|
+
server_name: Optional[str] = None,
|
|
1695
|
+
) -> MCPPromptResult:
|
|
1696
|
+
return run_sync(self.get_prompt_async(name, arguments, server_name))
|
|
1697
|
+
|
|
1698
|
+
# --------------------------------------------------------------------------
|
|
1699
|
+
# Tool Schema Conversion Aggregators
|
|
1700
|
+
# --------------------------------------------------------------------------
|
|
1701
|
+
|
|
1702
|
+
def get_openai_tools(self, server_name: Optional[str] = None, use_qualified_names: bool = False) -> List[Dict[str, Any]]:
|
|
1703
|
+
tools = self.list_tools(server_name)
|
|
1704
|
+
return [t.to_openai_tool(use_qualified_name=use_qualified_names) for t in tools]
|
|
1705
|
+
|
|
1706
|
+
def get_anthropic_tools(self, server_name: Optional[str] = None, use_qualified_names: bool = False) -> List[Dict[str, Any]]:
|
|
1707
|
+
tools = self.list_tools(server_name)
|
|
1708
|
+
return [t.to_anthropic_tool(use_qualified_name=use_qualified_names) for t in tools]
|
|
1709
|
+
|
|
1710
|
+
def get_gemini_tools(self, server_name: Optional[str] = None, use_qualified_names: bool = False) -> List[Dict[str, Any]]:
|
|
1711
|
+
tools = self.list_tools(server_name)
|
|
1712
|
+
return [t.to_gemini_tool(use_qualified_name=use_qualified_names) for t in tools]
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
# ==============================================================================
|
|
1716
|
+
# 6. Global Tool Schema Conversion Utilities
|
|
1717
|
+
# ==============================================================================
|
|
1718
|
+
|
|
1719
|
+
def convert_mcp_tool_to_openai(tool: Union[MCPTool, Dict[str, Any]]) -> Dict[str, Any]:
|
|
1720
|
+
"""Converts an MCP tool into an OpenAI function tool definition."""
|
|
1721
|
+
if isinstance(tool, MCPTool):
|
|
1722
|
+
return tool.to_openai_tool()
|
|
1723
|
+
t = MCPTool.from_dict(tool)
|
|
1724
|
+
return t.to_openai_tool()
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
def convert_mcp_tool_to_anthropic(tool: Union[MCPTool, Dict[str, Any]]) -> Dict[str, Any]:
|
|
1728
|
+
"""Converts an MCP tool into an Anthropic tool definition."""
|
|
1729
|
+
if isinstance(tool, MCPTool):
|
|
1730
|
+
return tool.to_anthropic_tool()
|
|
1731
|
+
t = MCPTool.from_dict(tool)
|
|
1732
|
+
return t.to_anthropic_tool()
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def convert_mcp_tool_to_gemini(tool: Union[MCPTool, Dict[str, Any]]) -> Dict[str, Any]:
|
|
1736
|
+
"""Converts an MCP tool into a Google Gemini function declaration."""
|
|
1737
|
+
if isinstance(tool, MCPTool):
|
|
1738
|
+
return tool.to_gemini_tool()
|
|
1739
|
+
t = MCPTool.from_dict(tool)
|
|
1740
|
+
return t.to_gemini_tool()
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def convert_mcp_tools_to_provider_schema(
|
|
1744
|
+
tools: List[Union[MCPTool, Dict[str, Any]]],
|
|
1745
|
+
provider: str = "openai",
|
|
1746
|
+
) -> List[Dict[str, Any]]:
|
|
1747
|
+
"""Converts a list of MCP tools into schemas matching the target LLM provider."""
|
|
1748
|
+
p = str(provider).lower().strip()
|
|
1749
|
+
result: List[Dict[str, Any]] = []
|
|
1750
|
+
for tool in tools:
|
|
1751
|
+
if "anthropic" in p or "claude" in p:
|
|
1752
|
+
result.append(convert_mcp_tool_to_anthropic(tool))
|
|
1753
|
+
elif "gemini" in p or "google" in p:
|
|
1754
|
+
result.append(convert_mcp_tool_to_gemini(tool))
|
|
1755
|
+
else:
|
|
1756
|
+
# Default to OpenAI / OpenAI-compatible / Ollama / llama.cpp
|
|
1757
|
+
result.append(convert_mcp_tool_to_openai(tool))
|
|
1758
|
+
return result
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
# ==============================================================================
|
|
1762
|
+
# 7. CLI Helper Functions
|
|
1763
|
+
# ==============================================================================
|
|
1764
|
+
|
|
1765
|
+
def mcp_list_servers(
|
|
1766
|
+
config_path: Optional[Union[str, Path]] = None,
|
|
1767
|
+
manager: Optional[MCPManager] = None,
|
|
1768
|
+
) -> List[Dict[str, Any]]:
|
|
1769
|
+
"""CLI Helper: lists all servers and their status."""
|
|
1770
|
+
mgr = manager or MCPManager(config_path=config_path)
|
|
1771
|
+
return mgr.list_servers()
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def mcp_add_server(
|
|
1775
|
+
name: str,
|
|
1776
|
+
command: Optional[str] = None,
|
|
1777
|
+
args: Optional[List[str]] = None,
|
|
1778
|
+
env: Optional[Dict[str, str]] = None,
|
|
1779
|
+
url: Optional[str] = None,
|
|
1780
|
+
transport: str = "stdio",
|
|
1781
|
+
config_path: Optional[Union[str, Path]] = None,
|
|
1782
|
+
) -> bool:
|
|
1783
|
+
"""CLI Helper: adds a new server definition to mcp.json."""
|
|
1784
|
+
mgr = MCPManager(config_path=config_path, auto_load=True)
|
|
1785
|
+
cfg = MCPServerConfig(
|
|
1786
|
+
name=name,
|
|
1787
|
+
command=command,
|
|
1788
|
+
args=args or [],
|
|
1789
|
+
env=env or {},
|
|
1790
|
+
url=url,
|
|
1791
|
+
transport=transport,
|
|
1792
|
+
)
|
|
1793
|
+
mgr.add_server(name, cfg, save=True)
|
|
1794
|
+
return True
|
|
1795
|
+
|
|
1796
|
+
|
|
1797
|
+
def mcp_remove_server(name: str, config_path: Optional[Union[str, Path]] = None) -> bool:
|
|
1798
|
+
"""CLI Helper: removes a server from mcp.json."""
|
|
1799
|
+
mgr = MCPManager(config_path=config_path, auto_load=True)
|
|
1800
|
+
return mgr.remove_server(name, save=True)
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def mcp_test_connection(
|
|
1804
|
+
name: str,
|
|
1805
|
+
config_path: Optional[Union[str, Path]] = None,
|
|
1806
|
+
manager: Optional[MCPManager] = None,
|
|
1807
|
+
) -> Dict[str, Any]:
|
|
1808
|
+
"""CLI Helper: tests connection to a named MCP server and queries its tools/resources."""
|
|
1809
|
+
mgr = manager or MCPManager(config_path=config_path)
|
|
1810
|
+
start_time = time.time()
|
|
1811
|
+
try:
|
|
1812
|
+
client = mgr.connect_server(name)
|
|
1813
|
+
ping_ok = client.ping()
|
|
1814
|
+
tools = client.list_tools(refresh=True)
|
|
1815
|
+
resources = client.list_resources(refresh=True)
|
|
1816
|
+
prompts = client.list_prompts(refresh=True)
|
|
1817
|
+
duration_ms = round((time.time() - start_time) * 1000, 2)
|
|
1818
|
+
|
|
1819
|
+
return {
|
|
1820
|
+
"success": True,
|
|
1821
|
+
"name": name,
|
|
1822
|
+
"connected": True,
|
|
1823
|
+
"ping": ping_ok,
|
|
1824
|
+
"duration_ms": duration_ms,
|
|
1825
|
+
"server_info": client.server_info,
|
|
1826
|
+
"protocol_version": client.protocol_version,
|
|
1827
|
+
"tools": [t.to_dict() for t in tools],
|
|
1828
|
+
"resources": [r.to_dict() for r in resources],
|
|
1829
|
+
"prompts": [p.to_dict() for p in prompts],
|
|
1830
|
+
"error": None,
|
|
1831
|
+
}
|
|
1832
|
+
except Exception as e:
|
|
1833
|
+
duration_ms = round((time.time() - start_time) * 1000, 2)
|
|
1834
|
+
return {
|
|
1835
|
+
"success": False,
|
|
1836
|
+
"name": name,
|
|
1837
|
+
"connected": False,
|
|
1838
|
+
"ping": False,
|
|
1839
|
+
"duration_ms": duration_ms,
|
|
1840
|
+
"server_info": {},
|
|
1841
|
+
"protocol_version": None,
|
|
1842
|
+
"tools": [],
|
|
1843
|
+
"resources": [],
|
|
1844
|
+
"prompts": [],
|
|
1845
|
+
"error": str(e),
|
|
1846
|
+
}
|