mita-code 0.1.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 (67) hide show
  1. mita/__init__.py +5 -0
  2. mita/__main__.py +5 -0
  3. mita/agent/__init__.py +1 -0
  4. mita/agent/context.py +43 -0
  5. mita/agent/conversation.py +101 -0
  6. mita/agent/loop.py +594 -0
  7. mita/agent/system_prompt.py +75 -0
  8. mita/cli.py +940 -0
  9. mita/config/__init__.py +6 -0
  10. mita/config/defaults.py +41 -0
  11. mita/config/loader.py +53 -0
  12. mita/config/schema.py +131 -0
  13. mita/hooks/__init__.py +1 -0
  14. mita/hooks/manager.py +94 -0
  15. mita/hooks/runner.py +145 -0
  16. mita/index/__init__.py +1 -0
  17. mita/index/embeddings.py +49 -0
  18. mita/index/manager.py +170 -0
  19. mita/index/parser.py +331 -0
  20. mita/index/retriever.py +53 -0
  21. mita/index/store.py +143 -0
  22. mita/llm/__init__.py +1 -0
  23. mita/llm/client.py +86 -0
  24. mita/llm/instructor.py +80 -0
  25. mita/llm/streaming.py +58 -0
  26. mita/memory/__init__.py +6 -0
  27. mita/memory/discovery.py +61 -0
  28. mita/memory/loader.py +76 -0
  29. mita/memory/manager.py +117 -0
  30. mita/models/__init__.py +13 -0
  31. mita/models/hardware.py +289 -0
  32. mita/models/manager.py +268 -0
  33. mita/models/ollama_client.py +104 -0
  34. mita/models/recommender.py +88 -0
  35. mita/models/registry.py +167 -0
  36. mita/models/server.py +262 -0
  37. mita/plugins/__init__.py +1 -0
  38. mita/plugins/client.py +152 -0
  39. mita/plugins/manager.py +210 -0
  40. mita/py.typed +0 -0
  41. mita/skills/__init__.py +1 -0
  42. mita/skills/executor.py +84 -0
  43. mita/skills/loader.py +117 -0
  44. mita/skills/manager.py +129 -0
  45. mita/tools/__init__.py +1 -0
  46. mita/tools/builtins/__init__.py +28 -0
  47. mita/tools/builtins/file_edit.py +71 -0
  48. mita/tools/builtins/file_read.py +74 -0
  49. mita/tools/builtins/file_write.py +42 -0
  50. mita/tools/builtins/git.py +112 -0
  51. mita/tools/builtins/glob_tool.py +67 -0
  52. mita/tools/builtins/grep_tool.py +93 -0
  53. mita/tools/builtins/shell.py +83 -0
  54. mita/tools/executor.py +80 -0
  55. mita/tools/registry.py +69 -0
  56. mita/tools/safety.py +91 -0
  57. mita/tools/schema.py +87 -0
  58. mita/ui/__init__.py +1 -0
  59. mita/ui/display.py +139 -0
  60. mita/ui/repl.py +88 -0
  61. mita/ui/spinner.py +48 -0
  62. mita/ui/theme.py +23 -0
  63. mita_code-0.1.0.dist-info/METADATA +227 -0
  64. mita_code-0.1.0.dist-info/RECORD +67 -0
  65. mita_code-0.1.0.dist-info/WHEEL +4 -0
  66. mita_code-0.1.0.dist-info/entry_points.txt +3 -0
  67. mita_code-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,88 @@
1
+ """Filter and rank models by hardware capability."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from mita.models.hardware import HardwareInfo
8
+ from mita.models.registry import ModelCard, get_coding_models
9
+
10
+
11
+ class ModelRecommendation(BaseModel):
12
+ """A model recommendation with a fit score and notes."""
13
+
14
+ model: ModelCard
15
+ fit_score: float # 0.0–1.0
16
+ notes: str
17
+
18
+
19
+ def recommend_models(hw: HardwareInfo) -> list[ModelRecommendation]:
20
+ """Recommend coding models that fit the given hardware.
21
+
22
+ Returns a list sorted by fit_score descending (best fit first).
23
+ Only includes models that can actually run on the hardware.
24
+ """
25
+ available_vram = hw.available_vram_gb
26
+ recommendations: list[ModelRecommendation] = []
27
+
28
+ for model in get_coding_models():
29
+ if model.min_vram_gb <= 0:
30
+ continue
31
+ if model.min_vram_gb > available_vram:
32
+ continue
33
+ if model.min_ram_gb > hw.ram_gb:
34
+ continue
35
+
36
+ fit_score, notes = _score_model(model, hw, available_vram)
37
+ recommendations.append(ModelRecommendation(model=model, fit_score=fit_score, notes=notes))
38
+
39
+ recommendations.sort(key=lambda r: r.fit_score, reverse=True)
40
+ return recommendations
41
+
42
+
43
+ def _score_model(model: ModelCard, hw: HardwareInfo, available_vram: float) -> tuple[float, str]:
44
+ """Score a model's fit for the hardware. Returns (score, notes)."""
45
+ notes_parts: list[str] = []
46
+
47
+ # Base score: how much headroom do we have?
48
+ # Ratio of available VRAM to required VRAM
49
+ headroom_ratio = available_vram / model.min_vram_gb
50
+ if headroom_ratio >= 2.0:
51
+ vram_score = 1.0
52
+ notes_parts.append(f"Plenty of headroom ({available_vram:.0f}GB available)")
53
+ elif headroom_ratio >= 1.5:
54
+ vram_score = 0.9
55
+ notes_parts.append(f"Comfortable fit ({available_vram:.0f}GB available)")
56
+ elif headroom_ratio >= 1.2:
57
+ vram_score = 0.7
58
+ notes_parts.append(f"Fits with some room ({available_vram:.0f}GB available)")
59
+ else:
60
+ vram_score = 0.5
61
+ notes_parts.append(f"Tight fit — uses ~{model.min_vram_gb:.0f}GB of {available_vram:.0f}GB")
62
+
63
+ # Bonus for tool call support (important for agentic use)
64
+ tool_bonus = 0.1 if model.tool_call_support else 0.0
65
+
66
+ # Bonus for being a recommended model
67
+ recommended_bonus = 0.05 if "recommended" in model.tags else 0.0
68
+
69
+ # Penalty for Apple Silicon running very large models (thermal throttling)
70
+ thermal_penalty = 0.0
71
+ if hw.apple_silicon and model.min_vram_gb > hw.ram_gb * 0.5:
72
+ thermal_penalty = 0.1
73
+ notes_parts.append("May cause thermal throttling on Apple Silicon")
74
+
75
+ # Context window bonus (larger is better for agentic coding)
76
+ ctx_bonus = min(0.05, model.context_window / 1_000_000)
77
+
78
+ score = min(1.0, vram_score + tool_bonus + recommended_bonus + ctx_bonus - thermal_penalty)
79
+
80
+ if model.tool_call_support:
81
+ notes_parts.append("Supports tool calls")
82
+ else:
83
+ notes_parts.append("No tool call support — will use JSON fallback")
84
+
85
+ if hw.unified_memory:
86
+ notes_parts.append("Unified memory — shared with system")
87
+
88
+ return round(score, 2), ". ".join(notes_parts)
@@ -0,0 +1,167 @@
1
+ """Curated registry of coding-focused models for Ollama."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ModelCard(BaseModel):
9
+ """Metadata for a coding-focused model in the curated registry."""
10
+
11
+ name: str # Ollama model tag, e.g. "qwen2.5-coder:7b"
12
+ family: str # "qwen", "codellama", "deepseek", etc.
13
+ param_count: str # "7B", "14B", "32B"
14
+ min_ram_gb: float
15
+ min_vram_gb: float
16
+ context_window: int
17
+ quantization: str = "Q4_K_M"
18
+ tool_call_support: bool = True
19
+ description: str = ""
20
+ tags: list[str] = Field(default_factory=list)
21
+
22
+
23
+ # Curated list of coding models that work well with Ollama
24
+ CODING_MODELS: list[ModelCard] = [
25
+ # ── Small (< 4GB) ────────────────────────────────────────────
26
+ ModelCard(
27
+ name="qwen2.5-coder:1.5b",
28
+ family="qwen",
29
+ param_count="1.5B",
30
+ min_ram_gb=2,
31
+ min_vram_gb=2,
32
+ context_window=32768,
33
+ description="Tiny but capable code model. Good for quick completions.",
34
+ tags=["code", "fast", "small"],
35
+ tool_call_support=False,
36
+ ),
37
+ ModelCard(
38
+ name="qwen2.5-coder:3b",
39
+ family="qwen",
40
+ param_count="3B",
41
+ min_ram_gb=3,
42
+ min_vram_gb=3,
43
+ context_window=32768,
44
+ description="Small code model. Good balance of speed and quality.",
45
+ tags=["code", "fast"],
46
+ tool_call_support=False,
47
+ ),
48
+ ModelCard(
49
+ name="deepseek-coder-v2:lite",
50
+ family="deepseek",
51
+ param_count="2.4B",
52
+ min_ram_gb=3,
53
+ min_vram_gb=2,
54
+ context_window=16384,
55
+ description="Lightweight DeepSeek coder variant.",
56
+ tags=["code", "fast", "small"],
57
+ tool_call_support=False,
58
+ ),
59
+ # ── Medium (4–8GB) ───────────────────────────────────────────
60
+ ModelCard(
61
+ name="qwen2.5-coder:7b",
62
+ family="qwen",
63
+ param_count="7B",
64
+ min_ram_gb=6,
65
+ min_vram_gb=5,
66
+ context_window=32768,
67
+ description="Strong code model. Best default for 8GB+ machines.",
68
+ tags=["code", "instruct", "recommended"],
69
+ ),
70
+ ModelCard(
71
+ name="codellama:7b",
72
+ family="codellama",
73
+ param_count="7B",
74
+ min_ram_gb=6,
75
+ min_vram_gb=5,
76
+ context_window=16384,
77
+ description="Meta's Code Llama. Solid general-purpose code model.",
78
+ tags=["code", "instruct"],
79
+ ),
80
+ ModelCard(
81
+ name="deepseek-coder-v2:16b",
82
+ family="deepseek",
83
+ param_count="16B",
84
+ min_ram_gb=10,
85
+ min_vram_gb=10,
86
+ context_window=65536,
87
+ description="DeepSeek Coder V2. Strong MoE architecture.",
88
+ tags=["code", "instruct"],
89
+ ),
90
+ # ── Large (8–16GB) ───────────────────────────────────────────
91
+ ModelCard(
92
+ name="qwen2.5-coder:14b",
93
+ family="qwen",
94
+ param_count="14B",
95
+ min_ram_gb=10,
96
+ min_vram_gb=10,
97
+ context_window=32768,
98
+ description="Excellent code model. Strong tool call support.",
99
+ tags=["code", "instruct", "recommended"],
100
+ ),
101
+ ModelCard(
102
+ name="codellama:13b",
103
+ family="codellama",
104
+ param_count="13B",
105
+ min_ram_gb=10,
106
+ min_vram_gb=10,
107
+ context_window=16384,
108
+ description="Larger Code Llama with better reasoning.",
109
+ tags=["code", "instruct"],
110
+ ),
111
+ # ── XL (16–32GB) ─────────────────────────────────────────────
112
+ ModelCard(
113
+ name="qwen2.5-coder:32b",
114
+ family="qwen",
115
+ param_count="32B",
116
+ min_ram_gb=20,
117
+ min_vram_gb=20,
118
+ context_window=32768,
119
+ description="Top-tier local code model. Excellent tool call reliability.",
120
+ tags=["code", "instruct", "recommended"],
121
+ ),
122
+ ModelCard(
123
+ name="codellama:34b",
124
+ family="codellama",
125
+ param_count="34B",
126
+ min_ram_gb=22,
127
+ min_vram_gb=22,
128
+ context_window=16384,
129
+ description="Largest Code Llama. Near-cloud quality.",
130
+ tags=["code", "instruct"],
131
+ ),
132
+ # ── Embedding models ─────────────────────────────────────────
133
+ ModelCard(
134
+ name="nomic-embed-text",
135
+ family="nomic",
136
+ param_count="137M",
137
+ min_ram_gb=1,
138
+ min_vram_gb=1,
139
+ context_window=8192,
140
+ description="Default embedding model for codebase indexing.",
141
+ tags=["embedding"],
142
+ tool_call_support=False,
143
+ ),
144
+ ]
145
+
146
+
147
+ def get_registry() -> list[ModelCard]:
148
+ """Return the full curated model registry."""
149
+ return list(CODING_MODELS)
150
+
151
+
152
+ def find_model(name: str) -> ModelCard | None:
153
+ """Find a model in the registry by name (exact match)."""
154
+ for model in CODING_MODELS:
155
+ if model.name == name:
156
+ return model
157
+ return None
158
+
159
+
160
+ def get_coding_models() -> list[ModelCard]:
161
+ """Return only coding models (exclude embedding models)."""
162
+ return [m for m in CODING_MODELS if "embedding" not in m.tags]
163
+
164
+
165
+ def get_embedding_models() -> list[ModelCard]:
166
+ """Return only embedding models."""
167
+ return [m for m in CODING_MODELS if "embedding" in m.tags]
mita/models/server.py ADDED
@@ -0,0 +1,262 @@
1
+ """Ollama server lifecycle management — start, stop, health check.
2
+
3
+ Runs Ollama as a detached daemon tracked via a PID file so it persists
4
+ across short-lived CLI commands like ``mita models list``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import signal
12
+ import subprocess
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from rich.console import Console
17
+
18
+ from mita.models.ollama_client import OllamaClient
19
+
20
+
21
+ def _get_pid_dir() -> Path:
22
+ """Return the directory for the PID file (lazy to avoid module-level Path.home())."""
23
+ return Path.home() / ".config" / "mita"
24
+
25
+
26
+ def _get_pid_file() -> Path:
27
+ """Return the PID file path (lazy to avoid module-level Path.home())."""
28
+ return _get_pid_dir() / "ollama.pid"
29
+
30
+
31
+ def find_ollama_binary() -> str | None:
32
+ """Locate the ollama binary on the system."""
33
+ return shutil.which("ollama")
34
+
35
+
36
+ def is_server_running(host: str = "http://localhost:11434", timeout: int = 5) -> bool:
37
+ """Check if an Ollama server is reachable."""
38
+ client = OllamaClient(host=host, timeout=timeout)
39
+ return client.is_running()
40
+
41
+
42
+ def _read_pid() -> int | None:
43
+ """Read the PID from the PID file, return None if missing or stale."""
44
+ try:
45
+ pid = int(_get_pid_file().read_text().strip())
46
+ # Check if process is alive
47
+ os.kill(pid, 0)
48
+ return pid
49
+ except (FileNotFoundError, ValueError, ProcessLookupError, PermissionError, OSError):
50
+ _remove_pid_file()
51
+ return None
52
+
53
+
54
+ def _write_pid(pid: int) -> None:
55
+ """Write a PID to the PID file."""
56
+ _get_pid_dir().mkdir(parents=True, exist_ok=True)
57
+ _get_pid_file().write_text(str(pid))
58
+
59
+
60
+ def _remove_pid_file() -> None:
61
+ """Remove the PID file if it exists."""
62
+ try:
63
+ _get_pid_file().unlink()
64
+ except FileNotFoundError:
65
+ pass
66
+
67
+
68
+ def start_server(
69
+ host: str = "http://localhost:11434",
70
+ timeout: int = 30,
71
+ console: Console | None = None,
72
+ ) -> bool:
73
+ """Start the Ollama server as a detached daemon.
74
+
75
+ The server persists after Mita exits. Its PID is tracked in
76
+ ``~/.config/mita/ollama.pid`` so ``stop_server`` can find it later.
77
+
78
+ Returns True if the server is running (either already was or we started it).
79
+ """
80
+ if is_server_running(host, timeout=5):
81
+ return True
82
+
83
+ binary = find_ollama_binary()
84
+ if binary is None:
85
+ if console:
86
+ console.print(
87
+ "[red]Ollama binary not found.[/red]\n"
88
+ "Install Ollama from [bold]https://ollama.com[/bold]"
89
+ )
90
+ return False
91
+
92
+ if console:
93
+ console.print("[dim]Starting Ollama server...[/dim]")
94
+
95
+ env = os.environ.copy()
96
+ env_host = host.replace("http://", "").replace("https://", "")
97
+ env["OLLAMA_HOST"] = env_host
98
+
99
+ try:
100
+ proc = subprocess.Popen(
101
+ [binary, "serve"],
102
+ stdout=subprocess.DEVNULL,
103
+ stderr=subprocess.DEVNULL,
104
+ env=env,
105
+ start_new_session=True,
106
+ )
107
+ except OSError as e:
108
+ if console:
109
+ console.print(f"[red]Failed to start Ollama: {e}[/red]")
110
+ return False
111
+
112
+ _write_pid(proc.pid)
113
+
114
+ # Wait for the server to become healthy
115
+ deadline = time.monotonic() + timeout
116
+ while time.monotonic() < deadline:
117
+ if proc.poll() is not None:
118
+ if console:
119
+ console.print("[red]Ollama server exited unexpectedly.[/red]")
120
+ _remove_pid_file()
121
+ return False
122
+ if is_server_running(host, timeout=2):
123
+ if console:
124
+ console.print("[green]Ollama server started.[/green]")
125
+ return True
126
+ time.sleep(0.5)
127
+
128
+ if console:
129
+ console.print("[red]Ollama server did not become ready in time.[/red]")
130
+ stop_server()
131
+ return False
132
+
133
+
134
+ def stop_server(console: Console | None = None) -> bool:
135
+ """Stop the Ollama server if it was started by Mita (tracked via PID file).
136
+
137
+ Returns True if we stopped it, False if there was nothing to stop.
138
+ """
139
+ pid = _read_pid()
140
+ if pid is None:
141
+ return False
142
+
143
+ try:
144
+ os.kill(pid, signal.SIGTERM)
145
+ # Wait for graceful shutdown
146
+ deadline = time.monotonic() + 10
147
+ while time.monotonic() < deadline:
148
+ try:
149
+ os.kill(pid, 0)
150
+ time.sleep(0.25)
151
+ except ProcessLookupError:
152
+ break
153
+ else:
154
+ # Still alive after 10s — force kill
155
+ try:
156
+ os.kill(pid, signal.SIGKILL)
157
+ except ProcessLookupError:
158
+ pass
159
+ except ProcessLookupError:
160
+ pass # Already dead
161
+
162
+ _remove_pid_file()
163
+
164
+ if console:
165
+ console.print("[dim]Ollama server stopped.[/dim]")
166
+
167
+ return True
168
+
169
+
170
+ def is_managed() -> bool:
171
+ """Return True if a Mita-started Ollama server is tracked and alive."""
172
+ return _read_pid() is not None
173
+
174
+
175
+ def ensure_server(
176
+ host: str = "http://localhost:11434",
177
+ auto_manage: bool = True,
178
+ console: Console | None = None,
179
+ ) -> bool:
180
+ """Ensure Ollama is available — start it if auto_manage is enabled."""
181
+ if is_server_running(host, timeout=5):
182
+ return True
183
+
184
+ if not auto_manage:
185
+ if console:
186
+ console.print(
187
+ "[red]Cannot connect to Ollama.[/red]\n"
188
+ "Start it manually with [bold]ollama serve[/bold] "
189
+ "or set [bold]ollama.auto_manage = true[/bold] in your config."
190
+ )
191
+ return False
192
+
193
+ return start_server(host=host, console=console)
194
+
195
+
196
+ def ensure_model(
197
+ model_name: str,
198
+ host: str = "http://localhost:11434",
199
+ timeout: int = 120,
200
+ console: Console | None = None,
201
+ ) -> bool:
202
+ """Ensure a model is installed, pulling it automatically if missing."""
203
+ client = OllamaClient(host=host, timeout=timeout)
204
+
205
+ # Check if model is already installed
206
+ try:
207
+ installed = client.list_models()
208
+ for m in installed:
209
+ # Match both exact name and name without tag
210
+ if m.name == model_name or m.name == f"{model_name}:latest":
211
+ return True
212
+ # Also match if user specified without tag
213
+ if m.name.split(":")[0] == model_name.split(":")[0] and (
214
+ ":" not in model_name or m.name == model_name
215
+ ):
216
+ return True
217
+ except (ConnectionError, OSError):
218
+ if console:
219
+ console.print("[red]Cannot connect to Ollama to check models.[/red]")
220
+ return False
221
+
222
+ # Model not found — pull it
223
+ if console:
224
+ console.print(f"[yellow]Model '{model_name}' is not installed.[/yellow]")
225
+ console.print(f"[dim]Pulling {model_name}...[/dim]")
226
+
227
+ try:
228
+ from rich.progress import (
229
+ BarColumn,
230
+ DownloadColumn,
231
+ Progress,
232
+ TextColumn,
233
+ TransferSpeedColumn,
234
+ )
235
+
236
+ if console:
237
+ with Progress(
238
+ TextColumn("[progress.description]{task.description}"),
239
+ BarColumn(),
240
+ DownloadColumn(),
241
+ TransferSpeedColumn(),
242
+ console=console,
243
+ ) as progress:
244
+ task = progress.add_task("Downloading", total=None)
245
+ for update in client.pull(model_name, stream=True):
246
+ status = update.get("status", "")
247
+ completed = update.get("completed", 0)
248
+ total = update.get("total", 0)
249
+ if total > 0:
250
+ progress.update(task, completed=completed, total=total, description=status)
251
+ else:
252
+ progress.update(task, description=status)
253
+ progress.update(task, description="Complete")
254
+ console.print(f"[green]Model '{model_name}' pulled successfully.[/green]")
255
+ else:
256
+ for _update in client.pull(model_name, stream=False):
257
+ pass
258
+ return True
259
+ except Exception as e:
260
+ if console:
261
+ console.print(f"[red]Failed to pull '{model_name}': {e}[/red]")
262
+ return False
@@ -0,0 +1 @@
1
+ """MCP plugin system — discover and call tools from MCP servers."""
mita/plugins/client.py ADDED
@@ -0,0 +1,152 @@
1
+ """MCP client wrapper for a single plugin server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from contextlib import AsyncExitStack
8
+ from typing import Any
9
+
10
+ from mcp import ClientSession, StdioServerParameters
11
+ from mcp.client.stdio import stdio_client
12
+
13
+ from mita.config.schema import PluginDefinition
14
+
15
+
16
+ class MCPPluginClient:
17
+ """Manages connection to a single MCP server."""
18
+
19
+ def __init__(self, plugin: PluginDefinition) -> None:
20
+ self._plugin = plugin
21
+ self._session: ClientSession | None = None
22
+ self._exit_stack: AsyncExitStack | None = None
23
+
24
+ @property
25
+ def name(self) -> str:
26
+ return self._plugin.name
27
+
28
+ @property
29
+ def transport(self) -> str:
30
+ return self._plugin.transport
31
+
32
+ @property
33
+ def connected(self) -> bool:
34
+ return self._session is not None
35
+
36
+ async def connect(self, timeout: float = 30.0) -> None:
37
+ """Connect to the MCP server and initialize the session."""
38
+ if self._session is not None:
39
+ return
40
+
41
+ self._exit_stack = AsyncExitStack()
42
+
43
+ if self._plugin.transport == "stdio":
44
+ await self._connect_stdio(timeout)
45
+ elif self._plugin.transport == "sse":
46
+ await self._connect_sse(timeout)
47
+ else:
48
+ raise ValueError(f"Unsupported transport: {self._plugin.transport}")
49
+
50
+ async def _connect_stdio(self, timeout: float) -> None:
51
+ """Connect via stdio transport (subprocess)."""
52
+ assert self._exit_stack is not None
53
+
54
+ if not self._plugin.command:
55
+ raise ValueError(f"Plugin '{self.name}' requires a command for stdio transport")
56
+
57
+ # Build environment: inherit current env, overlay plugin-specific vars
58
+ env: dict[str, str] = {**os.environ, **self._plugin.env}
59
+
60
+ server_params = StdioServerParameters(
61
+ command=self._plugin.command,
62
+ args=self._plugin.args,
63
+ env=env,
64
+ )
65
+
66
+ transport = await self._exit_stack.enter_async_context(stdio_client(server_params))
67
+ read_stream, write_stream = transport
68
+
69
+ session = await self._exit_stack.enter_async_context(
70
+ ClientSession(read_stream, write_stream)
71
+ )
72
+ await asyncio.wait_for(session.initialize(), timeout=timeout)
73
+ self._session = session
74
+
75
+ async def _connect_sse(self, timeout: float) -> None:
76
+ """Connect via SSE transport (HTTP)."""
77
+ assert self._exit_stack is not None
78
+
79
+ if not self._plugin.url:
80
+ raise ValueError(f"Plugin '{self.name}' requires a url for SSE transport")
81
+
82
+ from mcp.client.sse import sse_client
83
+
84
+ transport = await self._exit_stack.enter_async_context(sse_client(self._plugin.url))
85
+ read_stream, write_stream = transport
86
+
87
+ session = await self._exit_stack.enter_async_context(
88
+ ClientSession(read_stream, write_stream)
89
+ )
90
+ await asyncio.wait_for(session.initialize(), timeout=timeout)
91
+ self._session = session
92
+
93
+ async def disconnect(self) -> None:
94
+ """Disconnect from the MCP server and clean up resources."""
95
+ if self._exit_stack is not None:
96
+ await self._exit_stack.aclose()
97
+ self._exit_stack = None
98
+ self._session = None
99
+
100
+ async def list_tools(self) -> list[dict[str, Any]]:
101
+ """List tools provided by this MCP server.
102
+
103
+ Returns a list of dicts with name, description, and inputSchema.
104
+ """
105
+ if self._session is None:
106
+ raise RuntimeError(f"Plugin '{self.name}' is not connected")
107
+
108
+ response = await self._session.list_tools()
109
+ return [
110
+ {
111
+ "name": tool.name,
112
+ "description": tool.description or "",
113
+ "inputSchema": tool.inputSchema,
114
+ }
115
+ for tool in response.tools
116
+ ]
117
+
118
+ async def call_tool(
119
+ self, tool_name: str, arguments: dict[str, Any], timeout: float = 120.0
120
+ ) -> str:
121
+ """Call a tool on this MCP server.
122
+
123
+ Returns the text content from the tool result.
124
+ """
125
+ if self._session is None:
126
+ raise RuntimeError(f"Plugin '{self.name}' is not connected")
127
+
128
+ result = await asyncio.wait_for(
129
+ self._session.call_tool(tool_name, arguments),
130
+ timeout=timeout,
131
+ )
132
+
133
+ # Extract text content from result
134
+ parts: list[str] = []
135
+ for item in result.content:
136
+ if hasattr(item, "text"):
137
+ parts.append(item.text)
138
+ elif hasattr(item, "data"):
139
+ parts.append(f"[binary data: {getattr(item, 'mimeType', 'unknown')}]")
140
+ else:
141
+ parts.append(str(item))
142
+ return "\n".join(parts)
143
+
144
+ async def ping(self, timeout: float = 10.0) -> bool:
145
+ """Ping the MCP server to check connectivity."""
146
+ if self._session is None:
147
+ return False
148
+ try:
149
+ await asyncio.wait_for(self._session.send_ping(), timeout=timeout)
150
+ return True
151
+ except (TimeoutError, ConnectionError, OSError):
152
+ return False