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.
Files changed (75) hide show
  1. k_cli/__init__.py +77 -0
  2. k_cli/agents/__init__.py +0 -0
  3. k_cli/agents/adversarial_swarm.py +338 -0
  4. k_cli/agents/agent_core.py +255 -0
  5. k_cli/agents/background_daemon.py +141 -0
  6. k_cli/agents/orchestrator.py +376 -0
  7. k_cli/agents/persona.py +649 -0
  8. k_cli/agents/scaffold_engine.py +121 -0
  9. k_cli/agents/strands_agent.py +832 -0
  10. k_cli/agents/subagents.py +1496 -0
  11. k_cli/cli.py +3297 -0
  12. k_cli/core/__init__.py +0 -0
  13. k_cli/core/airgap.py +95 -0
  14. k_cli/core/credentials.py +548 -0
  15. k_cli/core/intent_sensor.py +177 -0
  16. k_cli/core/llm_driver.py +1028 -0
  17. k_cli/core/model_manager.py +1109 -0
  18. k_cli/core/models_hub.py +913 -0
  19. k_cli/core/prompting.py +41 -0
  20. k_cli/core/sdk.py +322 -0
  21. k_cli/core/session.py +826 -0
  22. k_cli/core/smart_router.py +230 -0
  23. k_cli/core/storage_manager.py +176 -0
  24. k_cli/core/viewport_engine.py +117 -0
  25. k_cli/demo/demo_runner.py +579 -0
  26. k_cli/git/__init__.py +0 -0
  27. k_cli/git/ai_bisect.py +208 -0
  28. k_cli/git/conflict_resolver.py +1039 -0
  29. k_cli/git/git_guard.py +417 -0
  30. k_cli/git/patcher.py +1175 -0
  31. k_cli/git/repo_map.py +1780 -0
  32. k_cli/git/smart_git.py +928 -0
  33. k_cli/git/verifier.py +969 -0
  34. k_cli/github/__init__.py +0 -0
  35. k_cli/github/dedup_engine.py +787 -0
  36. k_cli/github/github_client.py +1702 -0
  37. k_cli/github/github_engine.py +641 -0
  38. k_cli/github/local_hub.py +209 -0
  39. k_cli/github/pr_watcher.py +129 -0
  40. k_cli/github/trending.py +205 -0
  41. k_cli/tools/__init__.py +0 -0
  42. k_cli/tools/audit.py +79 -0
  43. k_cli/tools/chaos_immunity.py +377 -0
  44. k_cli/tools/codebase_qa.py +106 -0
  45. k_cli/tools/command_runner.py +256 -0
  46. k_cli/tools/diagram_generator.py +547 -0
  47. k_cli/tools/doc_retriever.py +1332 -0
  48. k_cli/tools/feature.py +105 -0
  49. k_cli/tools/ghost_daemon.py +122 -0
  50. k_cli/tools/incident_triage.py +1365 -0
  51. k_cli/tools/mcp_client.py +1846 -0
  52. k_cli/tools/repo_gardener.py +142 -0
  53. k_cli/tools/rules.py +109 -0
  54. k_cli/tools/security.py +52 -0
  55. k_cli/tools/security_healer.py +999 -0
  56. k_cli/tools/synapse_graph.py +155 -0
  57. k_cli/tui/__init__.py +0 -0
  58. k_cli/tui/diff_viewer.py +223 -0
  59. k_cli/tui/tui.py +1145 -0
  60. k_cli/tui/tui_animations.py +648 -0
  61. k_cli/tui/tui_app.py +2788 -0
  62. k_cli/ui/__init__.py +10 -0
  63. k_cli/ui/simple_repl.py +315 -0
  64. k_cli/web/__init__.py +7 -0
  65. k_cli/web/server.py +624 -0
  66. k_cli/web/static/app.js +830 -0
  67. k_cli/web/static/index.html +495 -0
  68. k_cli/web/static/monitor.html +189 -0
  69. k_cli/web/static/style.css +838 -0
  70. k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
  71. k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
  72. k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
  73. k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
  74. k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
  75. 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
+ }