axio-tools-mcp 0.2.2__tar.gz → 0.3.2__tar.gz
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.
- axio_tools_mcp-0.3.2/.gitignore +12 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/Makefile +1 -1
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/PKG-INFO +4 -1
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/pyproject.toml +6 -1
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/plugin.py +1 -1
- axio_tools_mcp-0.3.2/src/axio_tools_mcp/settings.py +29 -0
- axio_tools_mcp-0.3.2/src/axio_tools_mcp/tui/__init__.py +15 -0
- axio_tools_mcp-0.3.2/src/axio_tools_mcp/tui/screens.py +271 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_settings.py +2 -2
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/uv.lock +1 -1
- axio_tools_mcp-0.2.2/src/axio_tools_mcp/settings.py +0 -316
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/.github/workflows/publish.yml +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/.github/workflows/tests.yml +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/LICENSE +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/README.md +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/__init__.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/config.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/handler.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/loader.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/registry.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/session.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/conftest.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_config.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_handler.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_loader.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_registry.py +0 -0
- {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_session.py +0 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: axio-tools-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: MCP tools loader for Axio
|
|
5
|
+
Project-URL: Homepage, https://github.com/axio-agent/axio-tools-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/axio-agent/axio-tools-mcp
|
|
5
7
|
License: MIT
|
|
6
8
|
License-File: LICENSE
|
|
9
|
+
Keywords: agent,ai,llm,mcp,model-context-protocol,tools
|
|
7
10
|
Requires-Python: >=3.12
|
|
8
11
|
Requires-Dist: axio
|
|
9
12
|
Requires-Dist: mcp>=1.6
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "axio-tools-mcp"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.2"
|
|
4
4
|
description = "MCP tools loader for Axio"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
7
7
|
license = {text = "MIT"}
|
|
8
|
+
keywords = ["llm", "agent", "ai", "mcp", "tools", "model-context-protocol"]
|
|
8
9
|
dependencies = ["axio", "mcp>=1.6"]
|
|
9
10
|
|
|
10
11
|
[project.entry-points."axio.tools.settings"]
|
|
11
12
|
mcp = "axio_tools_mcp.plugin:MCPPlugin"
|
|
12
13
|
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/axio-agent/axio-tools-mcp"
|
|
17
|
+
Repository = "https://github.com/axio-agent/axio-tools-mcp"
|
|
13
18
|
[build-system]
|
|
14
19
|
requires = ["hatchling"]
|
|
15
20
|
build-backend = "hatchling.build"
|
|
@@ -35,7 +35,7 @@ class MCPPlugin:
|
|
|
35
35
|
return self._registry.all_tools
|
|
36
36
|
|
|
37
37
|
def settings_screen(self) -> Any:
|
|
38
|
-
from .
|
|
38
|
+
from .tui import MCPHubScreen
|
|
39
39
|
|
|
40
40
|
return MCPHubScreen(self._registry, config=self._config, global_config=self._global_config)
|
|
41
41
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Non-TUI helpers and sentinel for MCP settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _headers_to_str(headers: dict[str, str]) -> str:
|
|
7
|
+
return ", ".join(f"{k}:{v}" for k, v in headers.items())
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _parse_headers(raw: str) -> dict[str, str]:
|
|
11
|
+
result: dict[str, str] = {}
|
|
12
|
+
for pair in raw.split(","):
|
|
13
|
+
pair = pair.strip()
|
|
14
|
+
if ":" in pair:
|
|
15
|
+
key, value = pair.split(":", 1)
|
|
16
|
+
result[key.strip()] = value.strip()
|
|
17
|
+
return result
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _DeleteSentinel:
|
|
21
|
+
"""Sentinel object to signal a delete action from the edit screen."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_delete_sentinel() -> _DeleteSentinel:
|
|
25
|
+
return _DeleteSentinel()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_delete_sentinel(obj: object) -> bool:
|
|
29
|
+
return isinstance(obj, _DeleteSentinel)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""TUI screens for axio-tools-mcp (requires textual)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from .screens import MCPHubScreen, MCPServerEditScreen
|
|
7
|
+
|
|
8
|
+
__all__ = ["MCPHubScreen", "MCPServerEditScreen"]
|
|
9
|
+
except ImportError as _e:
|
|
10
|
+
import warnings
|
|
11
|
+
|
|
12
|
+
warnings.warn(
|
|
13
|
+
f"axio-tools-mcp TUI screens are unavailable: {_e}. Install textual: pip install axio[tui]",
|
|
14
|
+
stacklevel=1,
|
|
15
|
+
)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""TUI screens for MCP server management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Container, Horizontal
|
|
10
|
+
from textual.screen import ModalScreen
|
|
11
|
+
from textual.widgets import Button, Input, OptionList, Select, Static
|
|
12
|
+
|
|
13
|
+
from axio_tools_mcp.config import MCPServerConfig
|
|
14
|
+
from axio_tools_mcp.registry import MCPRegistry
|
|
15
|
+
from axio_tools_mcp.settings import (
|
|
16
|
+
_headers_to_str,
|
|
17
|
+
_is_delete_sentinel,
|
|
18
|
+
_make_delete_sentinel,
|
|
19
|
+
_parse_headers,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MCPServerEditScreen(ModalScreen["MCPServerConfig | None"]):
|
|
24
|
+
"""Per-server config: name, type, command/url, args, headers."""
|
|
25
|
+
|
|
26
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
27
|
+
CSS = """
|
|
28
|
+
MCPServerEditScreen { align: center middle; }
|
|
29
|
+
#mcp-edit {
|
|
30
|
+
width: 80;
|
|
31
|
+
height: auto;
|
|
32
|
+
max-height: 90%;
|
|
33
|
+
border: heavy $accent;
|
|
34
|
+
background: $panel;
|
|
35
|
+
padding: 1 2;
|
|
36
|
+
}
|
|
37
|
+
#mcp-edit Input { margin-bottom: 1; }
|
|
38
|
+
#mcp-edit Select { margin-bottom: 1; }
|
|
39
|
+
.mcp-buttons { height: auto; margin-top: 1; }
|
|
40
|
+
.mcp-buttons Button { margin: 0 1; }
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, config: MCPServerConfig | None = None, scope: str = "global") -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._editing = config
|
|
46
|
+
self._scope = scope
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
title = "Edit MCP Server" if self._editing else "Add MCP Server"
|
|
50
|
+
with Container(id="mcp-edit"):
|
|
51
|
+
yield Static(f"[bold]{title}[/]")
|
|
52
|
+
yield Static("Name:")
|
|
53
|
+
yield Input(
|
|
54
|
+
value=self._editing.name if self._editing else "",
|
|
55
|
+
placeholder="server name (e.g. filesystem)",
|
|
56
|
+
id="mcp-name",
|
|
57
|
+
)
|
|
58
|
+
yield Static("Type:")
|
|
59
|
+
is_http = self._editing is not None and self._editing.url is not None
|
|
60
|
+
yield Select(
|
|
61
|
+
[("stdio (subprocess)", "stdio"), ("HTTP", "http")],
|
|
62
|
+
value="http" if is_http else "stdio",
|
|
63
|
+
id="mcp-type",
|
|
64
|
+
)
|
|
65
|
+
yield Static("Command (stdio):")
|
|
66
|
+
yield Input(
|
|
67
|
+
value=self._editing.command or "" if self._editing else "",
|
|
68
|
+
placeholder="e.g. npx, uvx, python",
|
|
69
|
+
id="mcp-command",
|
|
70
|
+
)
|
|
71
|
+
yield Static("Args (comma-separated):")
|
|
72
|
+
yield Input(
|
|
73
|
+
value=", ".join(self._editing.args) if self._editing and self._editing.args else "",
|
|
74
|
+
placeholder="e.g. -m, mcp_server",
|
|
75
|
+
id="mcp-args",
|
|
76
|
+
)
|
|
77
|
+
yield Static("URL (HTTP):")
|
|
78
|
+
yield Input(
|
|
79
|
+
value=self._editing.url or "" if self._editing else "",
|
|
80
|
+
placeholder="e.g. http://localhost:8000/mcp",
|
|
81
|
+
id="mcp-url",
|
|
82
|
+
)
|
|
83
|
+
yield Static("Headers (key:value, comma-separated):")
|
|
84
|
+
yield Input(
|
|
85
|
+
value=_headers_to_str(self._editing.headers) if self._editing else "",
|
|
86
|
+
placeholder="e.g. Authorization:Bearer xxx",
|
|
87
|
+
id="mcp-headers",
|
|
88
|
+
)
|
|
89
|
+
yield Static("Scope:")
|
|
90
|
+
yield Select(
|
|
91
|
+
[("Global", "global"), ("Project", "project")],
|
|
92
|
+
value=self._scope,
|
|
93
|
+
id="mcp-scope",
|
|
94
|
+
)
|
|
95
|
+
with Horizontal(classes="mcp-buttons"):
|
|
96
|
+
yield Button("Save", id="btn-save", variant="primary")
|
|
97
|
+
if self._editing:
|
|
98
|
+
yield Button("Delete", id="btn-delete", variant="error")
|
|
99
|
+
yield Button("Cancel", id="btn-cancel")
|
|
100
|
+
|
|
101
|
+
def on_mount(self) -> None:
|
|
102
|
+
self.query_one("#mcp-name", Input).focus()
|
|
103
|
+
|
|
104
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
105
|
+
if event.button.id == "btn-cancel":
|
|
106
|
+
self.dismiss(None)
|
|
107
|
+
elif event.button.id == "btn-delete":
|
|
108
|
+
self.dismiss(_make_delete_sentinel()) # type: ignore[arg-type]
|
|
109
|
+
elif event.button.id == "btn-save":
|
|
110
|
+
self._save()
|
|
111
|
+
|
|
112
|
+
def _save(self) -> None:
|
|
113
|
+
name = self.query_one("#mcp-name", Input).value.strip()
|
|
114
|
+
if not name:
|
|
115
|
+
self.notify("Name is required", severity="error")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
transport_type = self.query_one("#mcp-type", Select).value
|
|
119
|
+
|
|
120
|
+
command: str | None = None
|
|
121
|
+
url: str | None = None
|
|
122
|
+
args: list[str] = []
|
|
123
|
+
headers: dict[str, str] = {}
|
|
124
|
+
|
|
125
|
+
if transport_type == "stdio":
|
|
126
|
+
command = self.query_one("#mcp-command", Input).value.strip()
|
|
127
|
+
if not command:
|
|
128
|
+
self.notify("Command is required for stdio transport", severity="error")
|
|
129
|
+
return
|
|
130
|
+
raw_args = self.query_one("#mcp-args", Input).value.strip()
|
|
131
|
+
if raw_args:
|
|
132
|
+
args = [a.strip() for a in raw_args.split(",") if a.strip()]
|
|
133
|
+
else:
|
|
134
|
+
url = self.query_one("#mcp-url", Input).value.strip()
|
|
135
|
+
if not url:
|
|
136
|
+
self.notify("URL is required for HTTP transport", severity="error")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
raw_headers = self.query_one("#mcp-headers", Input).value.strip()
|
|
140
|
+
if raw_headers:
|
|
141
|
+
headers = _parse_headers(raw_headers)
|
|
142
|
+
|
|
143
|
+
scope = str(self.query_one("#mcp-scope", Select).value)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
config = MCPServerConfig(
|
|
147
|
+
name=name,
|
|
148
|
+
command=command,
|
|
149
|
+
url=url,
|
|
150
|
+
args=args,
|
|
151
|
+
headers=headers,
|
|
152
|
+
scope=scope,
|
|
153
|
+
)
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
self.notify(str(exc), severity="error")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
self.dismiss(config)
|
|
159
|
+
|
|
160
|
+
def action_cancel(self) -> None:
|
|
161
|
+
self.dismiss(None)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class MCPHubScreen(ModalScreen[None]):
|
|
165
|
+
"""Command palette entry point: lists configured MCP server instances."""
|
|
166
|
+
|
|
167
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
168
|
+
CSS = """
|
|
169
|
+
MCPHubScreen { align: center middle; }
|
|
170
|
+
#mcp-hub {
|
|
171
|
+
width: 80;
|
|
172
|
+
height: 80%;
|
|
173
|
+
border: heavy $accent;
|
|
174
|
+
background: $panel;
|
|
175
|
+
padding: 1 2;
|
|
176
|
+
}
|
|
177
|
+
#mcp-list { height: 1fr; }
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
registry: MCPRegistry,
|
|
183
|
+
config: Any = None,
|
|
184
|
+
global_config: Any = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
super().__init__()
|
|
187
|
+
self._registry = registry
|
|
188
|
+
self._config = config
|
|
189
|
+
self._global_config = global_config
|
|
190
|
+
|
|
191
|
+
def _format_entries(self) -> list[str]:
|
|
192
|
+
entries = ["+ Add MCP Server"]
|
|
193
|
+
for name in self._registry.server_names:
|
|
194
|
+
status = self._registry.server_status(name)
|
|
195
|
+
count = self._registry.server_tool_count(name)
|
|
196
|
+
config = self._registry.server_config(name)
|
|
197
|
+
endpoint = config.command or config.url or ""
|
|
198
|
+
scope_db = self._registry.get_server_scope(name)
|
|
199
|
+
badge = "[G]" if scope_db is not self._config else "[P]"
|
|
200
|
+
if status == "connected":
|
|
201
|
+
entries.append(f"{badge} [*] {name:<20} ({count} tools) {endpoint}")
|
|
202
|
+
elif status == "error":
|
|
203
|
+
entries.append(f"{badge} [!] {name:<20} (error) {endpoint}")
|
|
204
|
+
else:
|
|
205
|
+
entries.append(f"{badge} [-] {name:<20} (disconnected) {endpoint}")
|
|
206
|
+
return entries
|
|
207
|
+
|
|
208
|
+
def compose(self) -> ComposeResult:
|
|
209
|
+
with Container(id="mcp-hub"):
|
|
210
|
+
yield Static("[bold]Manage MCP Servers[/]")
|
|
211
|
+
yield OptionList(*self._format_entries(), id="mcp-list")
|
|
212
|
+
|
|
213
|
+
def on_mount(self) -> None:
|
|
214
|
+
self.query_one("#mcp-list", OptionList).focus()
|
|
215
|
+
|
|
216
|
+
def _refresh_list(self) -> None:
|
|
217
|
+
ol = self.query_one("#mcp-list", OptionList)
|
|
218
|
+
ol.clear_options()
|
|
219
|
+
for entry in self._format_entries():
|
|
220
|
+
ol.add_option(entry)
|
|
221
|
+
|
|
222
|
+
def _scope_label(self, name: str) -> str:
|
|
223
|
+
scope_db = self._registry.get_server_scope(name)
|
|
224
|
+
return "project" if scope_db is self._config else "global"
|
|
225
|
+
|
|
226
|
+
def on_option_list_option_selected(self, message: OptionList.OptionSelected) -> None:
|
|
227
|
+
idx = message.option_index
|
|
228
|
+
if idx == 0:
|
|
229
|
+
self.app.push_screen(MCPServerEditScreen(), self._on_add_dismissed)
|
|
230
|
+
else:
|
|
231
|
+
server_idx = idx - 1
|
|
232
|
+
names = self._registry.server_names
|
|
233
|
+
if 0 <= server_idx < len(names):
|
|
234
|
+
name = names[server_idx]
|
|
235
|
+
config = self._registry.server_config(name)
|
|
236
|
+
scope = self._scope_label(name)
|
|
237
|
+
self.app.push_screen(
|
|
238
|
+
MCPServerEditScreen(config, scope=scope),
|
|
239
|
+
lambda result, n=name: self._on_edit_dismissed(n, result),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _on_add_dismissed(self, result: Any) -> None:
|
|
243
|
+
if result is None:
|
|
244
|
+
return
|
|
245
|
+
self.app.run_worker(self._do_add(result))
|
|
246
|
+
|
|
247
|
+
def _scope_db(self, scope: str) -> Any:
|
|
248
|
+
return self._config if scope == "project" else self._global_config
|
|
249
|
+
|
|
250
|
+
async def _do_add(self, config: MCPServerConfig) -> None:
|
|
251
|
+
await self._registry.add_server(config, scope=self._scope_db(config.scope))
|
|
252
|
+
self._refresh_list()
|
|
253
|
+
|
|
254
|
+
def _on_edit_dismissed(self, name: str, result: Any) -> None:
|
|
255
|
+
if result is None:
|
|
256
|
+
return
|
|
257
|
+
if _is_delete_sentinel(result):
|
|
258
|
+
self.app.run_worker(self._do_remove(name))
|
|
259
|
+
else:
|
|
260
|
+
self.app.run_worker(self._do_update(name, result))
|
|
261
|
+
|
|
262
|
+
async def _do_remove(self, name: str) -> None:
|
|
263
|
+
await self._registry.remove_server(name)
|
|
264
|
+
self._refresh_list()
|
|
265
|
+
|
|
266
|
+
async def _do_update(self, name: str, config: MCPServerConfig) -> None:
|
|
267
|
+
await self._registry.update_server(name, config, scope=self._scope_db(config.scope))
|
|
268
|
+
self._refresh_list()
|
|
269
|
+
|
|
270
|
+
def action_cancel(self) -> None:
|
|
271
|
+
self.dismiss(None)
|
|
@@ -28,13 +28,13 @@ def test_parse_headers_multiple() -> None:
|
|
|
28
28
|
|
|
29
29
|
def test_import_hub_screen() -> None:
|
|
30
30
|
"""MCPHubScreen can be imported."""
|
|
31
|
-
from axio_tools_mcp.
|
|
31
|
+
from axio_tools_mcp.tui import MCPHubScreen
|
|
32
32
|
|
|
33
33
|
assert MCPHubScreen is not None
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
def test_import_edit_screen() -> None:
|
|
37
37
|
"""MCPServerEditScreen can be imported."""
|
|
38
|
-
from axio_tools_mcp.
|
|
38
|
+
from axio_tools_mcp.tui import MCPServerEditScreen
|
|
39
39
|
|
|
40
40
|
assert MCPServerEditScreen is not None
|
|
@@ -1,316 +0,0 @@
|
|
|
1
|
-
"""TUI settings screens for MCP server management."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import TYPE_CHECKING, Any
|
|
6
|
-
|
|
7
|
-
try:
|
|
8
|
-
from textual.app import ComposeResult
|
|
9
|
-
from textual.binding import Binding
|
|
10
|
-
from textual.containers import Container, Horizontal
|
|
11
|
-
from textual.screen import ModalScreen
|
|
12
|
-
from textual.widgets import Button, Input, OptionList, Select, Static
|
|
13
|
-
|
|
14
|
-
_HAS_TEXTUAL = True
|
|
15
|
-
except ImportError:
|
|
16
|
-
_HAS_TEXTUAL = False
|
|
17
|
-
|
|
18
|
-
if TYPE_CHECKING:
|
|
19
|
-
from .config import MCPServerConfig
|
|
20
|
-
from .registry import MCPRegistry
|
|
21
|
-
|
|
22
|
-
_DELETE_SENTINEL = "__DELETE__"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if _HAS_TEXTUAL:
|
|
26
|
-
|
|
27
|
-
class MCPServerEditScreen(ModalScreen["MCPServerConfig | None"]):
|
|
28
|
-
"""Per-server config: name, type, command/url, args, headers."""
|
|
29
|
-
|
|
30
|
-
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
31
|
-
CSS = """
|
|
32
|
-
MCPServerEditScreen { align: center middle; }
|
|
33
|
-
#mcp-edit {
|
|
34
|
-
width: 80;
|
|
35
|
-
height: auto;
|
|
36
|
-
max-height: 90%;
|
|
37
|
-
border: heavy $accent;
|
|
38
|
-
background: $panel;
|
|
39
|
-
padding: 1 2;
|
|
40
|
-
}
|
|
41
|
-
#mcp-edit Input { margin-bottom: 1; }
|
|
42
|
-
#mcp-edit Select { margin-bottom: 1; }
|
|
43
|
-
.mcp-buttons { height: auto; margin-top: 1; }
|
|
44
|
-
.mcp-buttons Button { margin: 0 1; }
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
def __init__(self, config: MCPServerConfig | None = None, scope: str = "global") -> None:
|
|
48
|
-
super().__init__()
|
|
49
|
-
self._editing = config
|
|
50
|
-
self._scope = scope
|
|
51
|
-
|
|
52
|
-
def compose(self) -> ComposeResult:
|
|
53
|
-
title = "Edit MCP Server" if self._editing else "Add MCP Server"
|
|
54
|
-
with Container(id="mcp-edit"):
|
|
55
|
-
yield Static(f"[bold]{title}[/]")
|
|
56
|
-
yield Static("Name:")
|
|
57
|
-
yield Input(
|
|
58
|
-
value=self._editing.name if self._editing else "",
|
|
59
|
-
placeholder="server name (e.g. filesystem)",
|
|
60
|
-
id="mcp-name",
|
|
61
|
-
)
|
|
62
|
-
yield Static("Type:")
|
|
63
|
-
is_http = self._editing is not None and self._editing.url is not None
|
|
64
|
-
yield Select(
|
|
65
|
-
[("stdio (subprocess)", "stdio"), ("HTTP", "http")],
|
|
66
|
-
value="http" if is_http else "stdio",
|
|
67
|
-
id="mcp-type",
|
|
68
|
-
)
|
|
69
|
-
yield Static("Command (stdio):")
|
|
70
|
-
yield Input(
|
|
71
|
-
value=self._editing.command or "" if self._editing else "",
|
|
72
|
-
placeholder="e.g. npx, uvx, python",
|
|
73
|
-
id="mcp-command",
|
|
74
|
-
)
|
|
75
|
-
yield Static("Args (comma-separated):")
|
|
76
|
-
yield Input(
|
|
77
|
-
value=", ".join(self._editing.args) if self._editing and self._editing.args else "",
|
|
78
|
-
placeholder="e.g. -m, mcp_server",
|
|
79
|
-
id="mcp-args",
|
|
80
|
-
)
|
|
81
|
-
yield Static("URL (HTTP):")
|
|
82
|
-
yield Input(
|
|
83
|
-
value=self._editing.url or "" if self._editing else "",
|
|
84
|
-
placeholder="e.g. http://localhost:8000/mcp",
|
|
85
|
-
id="mcp-url",
|
|
86
|
-
)
|
|
87
|
-
yield Static("Headers (key:value, comma-separated):")
|
|
88
|
-
yield Input(
|
|
89
|
-
value=_headers_to_str(self._editing.headers) if self._editing else "",
|
|
90
|
-
placeholder="e.g. Authorization:Bearer xxx",
|
|
91
|
-
id="mcp-headers",
|
|
92
|
-
)
|
|
93
|
-
yield Static("Scope:")
|
|
94
|
-
yield Select(
|
|
95
|
-
[("Global", "global"), ("Project", "project")],
|
|
96
|
-
value=self._scope,
|
|
97
|
-
id="mcp-scope",
|
|
98
|
-
)
|
|
99
|
-
with Horizontal(classes="mcp-buttons"):
|
|
100
|
-
yield Button("Save", id="btn-save", variant="primary")
|
|
101
|
-
if self._editing:
|
|
102
|
-
yield Button("Delete", id="btn-delete", variant="error")
|
|
103
|
-
yield Button("Cancel", id="btn-cancel")
|
|
104
|
-
|
|
105
|
-
def on_mount(self) -> None:
|
|
106
|
-
self.query_one("#mcp-name", Input).focus()
|
|
107
|
-
|
|
108
|
-
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
109
|
-
if event.button.id == "btn-cancel":
|
|
110
|
-
self.dismiss(None)
|
|
111
|
-
elif event.button.id == "btn-delete":
|
|
112
|
-
self.dismiss(_make_delete_sentinel()) # type: ignore[arg-type]
|
|
113
|
-
elif event.button.id == "btn-save":
|
|
114
|
-
self._save()
|
|
115
|
-
|
|
116
|
-
def _save(self) -> None:
|
|
117
|
-
from .config import MCPServerConfig
|
|
118
|
-
|
|
119
|
-
name = self.query_one("#mcp-name", Input).value.strip()
|
|
120
|
-
if not name:
|
|
121
|
-
self.notify("Name is required", severity="error")
|
|
122
|
-
return
|
|
123
|
-
|
|
124
|
-
transport_type = self.query_one("#mcp-type", Select).value
|
|
125
|
-
|
|
126
|
-
command: str | None = None
|
|
127
|
-
url: str | None = None
|
|
128
|
-
args: list[str] = []
|
|
129
|
-
headers: dict[str, str] = {}
|
|
130
|
-
|
|
131
|
-
if transport_type == "stdio":
|
|
132
|
-
command = self.query_one("#mcp-command", Input).value.strip()
|
|
133
|
-
if not command:
|
|
134
|
-
self.notify("Command is required for stdio transport", severity="error")
|
|
135
|
-
return
|
|
136
|
-
raw_args = self.query_one("#mcp-args", Input).value.strip()
|
|
137
|
-
if raw_args:
|
|
138
|
-
args = [a.strip() for a in raw_args.split(",") if a.strip()]
|
|
139
|
-
else:
|
|
140
|
-
url = self.query_one("#mcp-url", Input).value.strip()
|
|
141
|
-
if not url:
|
|
142
|
-
self.notify("URL is required for HTTP transport", severity="error")
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
raw_headers = self.query_one("#mcp-headers", Input).value.strip()
|
|
146
|
-
if raw_headers:
|
|
147
|
-
headers = _parse_headers(raw_headers)
|
|
148
|
-
|
|
149
|
-
scope = str(self.query_one("#mcp-scope", Select).value)
|
|
150
|
-
|
|
151
|
-
try:
|
|
152
|
-
config = MCPServerConfig(
|
|
153
|
-
name=name,
|
|
154
|
-
command=command,
|
|
155
|
-
url=url,
|
|
156
|
-
args=args,
|
|
157
|
-
headers=headers,
|
|
158
|
-
scope=scope,
|
|
159
|
-
)
|
|
160
|
-
except ValueError as exc:
|
|
161
|
-
self.notify(str(exc), severity="error")
|
|
162
|
-
return
|
|
163
|
-
|
|
164
|
-
self.dismiss(config)
|
|
165
|
-
|
|
166
|
-
def action_cancel(self) -> None:
|
|
167
|
-
self.dismiss(None)
|
|
168
|
-
|
|
169
|
-
class MCPHubScreen(ModalScreen[None]):
|
|
170
|
-
"""Command palette entry point: lists configured MCP server instances."""
|
|
171
|
-
|
|
172
|
-
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
173
|
-
CSS = """
|
|
174
|
-
MCPHubScreen { align: center middle; }
|
|
175
|
-
#mcp-hub {
|
|
176
|
-
width: 80;
|
|
177
|
-
height: 80%;
|
|
178
|
-
border: heavy $accent;
|
|
179
|
-
background: $panel;
|
|
180
|
-
padding: 1 2;
|
|
181
|
-
}
|
|
182
|
-
#mcp-list { height: 1fr; }
|
|
183
|
-
"""
|
|
184
|
-
|
|
185
|
-
def __init__(
|
|
186
|
-
self,
|
|
187
|
-
registry: MCPRegistry,
|
|
188
|
-
config: Any = None,
|
|
189
|
-
global_config: Any = None,
|
|
190
|
-
) -> None:
|
|
191
|
-
super().__init__()
|
|
192
|
-
self._registry = registry
|
|
193
|
-
self._config = config
|
|
194
|
-
self._global_config = global_config
|
|
195
|
-
|
|
196
|
-
def _format_entries(self) -> list[str]:
|
|
197
|
-
entries = ["+ Add MCP Server"]
|
|
198
|
-
for name in self._registry.server_names:
|
|
199
|
-
status = self._registry.server_status(name)
|
|
200
|
-
count = self._registry.server_tool_count(name)
|
|
201
|
-
config = self._registry.server_config(name)
|
|
202
|
-
endpoint = config.command or config.url or ""
|
|
203
|
-
scope_db = self._registry.get_server_scope(name)
|
|
204
|
-
badge = "[G]" if scope_db is not self._config else "[P]"
|
|
205
|
-
if status == "connected":
|
|
206
|
-
entries.append(f"{badge} [*] {name:<20} ({count} tools) {endpoint}")
|
|
207
|
-
elif status == "error":
|
|
208
|
-
entries.append(f"{badge} [!] {name:<20} (error) {endpoint}")
|
|
209
|
-
else:
|
|
210
|
-
entries.append(f"{badge} [-] {name:<20} (disconnected) {endpoint}")
|
|
211
|
-
return entries
|
|
212
|
-
|
|
213
|
-
def compose(self) -> ComposeResult:
|
|
214
|
-
with Container(id="mcp-hub"):
|
|
215
|
-
yield Static("[bold]Manage MCP Servers[/]")
|
|
216
|
-
yield OptionList(*self._format_entries(), id="mcp-list")
|
|
217
|
-
|
|
218
|
-
def on_mount(self) -> None:
|
|
219
|
-
self.query_one("#mcp-list", OptionList).focus()
|
|
220
|
-
|
|
221
|
-
def _refresh_list(self) -> None:
|
|
222
|
-
ol = self.query_one("#mcp-list", OptionList)
|
|
223
|
-
ol.clear_options()
|
|
224
|
-
for entry in self._format_entries():
|
|
225
|
-
ol.add_option(entry)
|
|
226
|
-
|
|
227
|
-
def _scope_label(self, name: str) -> str:
|
|
228
|
-
scope_db = self._registry.get_server_scope(name)
|
|
229
|
-
return "project" if scope_db is self._config else "global"
|
|
230
|
-
|
|
231
|
-
def on_option_list_option_selected(self, message: OptionList.OptionSelected) -> None:
|
|
232
|
-
idx = message.option_index
|
|
233
|
-
if idx == 0:
|
|
234
|
-
self.app.push_screen(MCPServerEditScreen(), self._on_add_dismissed)
|
|
235
|
-
else:
|
|
236
|
-
server_idx = idx - 1
|
|
237
|
-
names = self._registry.server_names
|
|
238
|
-
if 0 <= server_idx < len(names):
|
|
239
|
-
name = names[server_idx]
|
|
240
|
-
config = self._registry.server_config(name)
|
|
241
|
-
scope = self._scope_label(name)
|
|
242
|
-
self.app.push_screen(
|
|
243
|
-
MCPServerEditScreen(config, scope=scope),
|
|
244
|
-
lambda result, n=name: self._on_edit_dismissed(n, result),
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
def _on_add_dismissed(self, result: Any) -> None:
|
|
248
|
-
if result is None:
|
|
249
|
-
return
|
|
250
|
-
self.app.run_worker(self._do_add(result))
|
|
251
|
-
|
|
252
|
-
def _scope_db(self, scope: str) -> Any:
|
|
253
|
-
return self._config if scope == "project" else self._global_config
|
|
254
|
-
|
|
255
|
-
async def _do_add(self, config: MCPServerConfig) -> None:
|
|
256
|
-
await self._registry.add_server(config, scope=self._scope_db(config.scope))
|
|
257
|
-
self._refresh_list()
|
|
258
|
-
|
|
259
|
-
def _on_edit_dismissed(self, name: str, result: Any) -> None:
|
|
260
|
-
if result is None:
|
|
261
|
-
return
|
|
262
|
-
if _is_delete_sentinel(result):
|
|
263
|
-
self.app.run_worker(self._do_remove(name))
|
|
264
|
-
else:
|
|
265
|
-
self.app.run_worker(self._do_update(name, result))
|
|
266
|
-
|
|
267
|
-
async def _do_remove(self, name: str) -> None:
|
|
268
|
-
await self._registry.remove_server(name)
|
|
269
|
-
self._refresh_list()
|
|
270
|
-
|
|
271
|
-
async def _do_update(self, name: str, config: MCPServerConfig) -> None:
|
|
272
|
-
await self._registry.update_server(name, config, scope=self._scope_db(config.scope))
|
|
273
|
-
self._refresh_list()
|
|
274
|
-
|
|
275
|
-
def action_cancel(self) -> None:
|
|
276
|
-
self.dismiss(None)
|
|
277
|
-
|
|
278
|
-
else:
|
|
279
|
-
|
|
280
|
-
class MCPHubScreen: # type: ignore[no-redef]
|
|
281
|
-
"""Stub when textual is not installed."""
|
|
282
|
-
|
|
283
|
-
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
284
|
-
raise ImportError("textual is required for MCP settings screens")
|
|
285
|
-
|
|
286
|
-
class MCPServerEditScreen: # type: ignore[no-redef]
|
|
287
|
-
"""Stub when textual is not installed."""
|
|
288
|
-
|
|
289
|
-
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
290
|
-
raise ImportError("textual is required for MCP settings screens")
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def _headers_to_str(headers: dict[str, str]) -> str:
|
|
294
|
-
return ", ".join(f"{k}:{v}" for k, v in headers.items())
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
def _parse_headers(raw: str) -> dict[str, str]:
|
|
298
|
-
result: dict[str, str] = {}
|
|
299
|
-
for pair in raw.split(","):
|
|
300
|
-
pair = pair.strip()
|
|
301
|
-
if ":" in pair:
|
|
302
|
-
key, value = pair.split(":", 1)
|
|
303
|
-
result[key.strip()] = value.strip()
|
|
304
|
-
return result
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
class _DeleteSentinel:
|
|
308
|
-
"""Sentinel object to signal a delete action from the edit screen."""
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def _make_delete_sentinel() -> _DeleteSentinel:
|
|
312
|
-
return _DeleteSentinel()
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def _is_delete_sentinel(obj: object) -> bool:
|
|
316
|
-
return isinstance(obj, _DeleteSentinel)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|