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.
Files changed (27) hide show
  1. axio_tools_mcp-0.3.2/.gitignore +12 -0
  2. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/Makefile +1 -1
  3. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/PKG-INFO +4 -1
  4. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/pyproject.toml +6 -1
  5. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/plugin.py +1 -1
  6. axio_tools_mcp-0.3.2/src/axio_tools_mcp/settings.py +29 -0
  7. axio_tools_mcp-0.3.2/src/axio_tools_mcp/tui/__init__.py +15 -0
  8. axio_tools_mcp-0.3.2/src/axio_tools_mcp/tui/screens.py +271 -0
  9. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_settings.py +2 -2
  10. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/uv.lock +1 -1
  11. axio_tools_mcp-0.2.2/src/axio_tools_mcp/settings.py +0 -316
  12. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/.github/workflows/publish.yml +0 -0
  13. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/.github/workflows/tests.yml +0 -0
  14. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/LICENSE +0 -0
  15. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/README.md +0 -0
  16. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/__init__.py +0 -0
  17. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/config.py +0 -0
  18. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/handler.py +0 -0
  19. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/loader.py +0 -0
  20. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/registry.py +0 -0
  21. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/src/axio_tools_mcp/session.py +0 -0
  22. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/conftest.py +0 -0
  23. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_config.py +0 -0
  24. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_handler.py +0 -0
  25. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_loader.py +0 -0
  26. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_registry.py +0 -0
  27. {axio_tools_mcp-0.2.2 → axio_tools_mcp-0.3.2}/tests/test_session.py +0 -0
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .pytest_cache/
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ _build/
12
+ .DS_Store
@@ -8,7 +8,7 @@ lint:
8
8
  uv run ruff check src/ tests/
9
9
 
10
10
  typecheck:
11
- uv run mypy src/
11
+ uv run mypy src/ tests/
12
12
 
13
13
  test:
14
14
  uv run pytest tests/ -v
@@ -1,9 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: axio-tools-mcp
3
- Version: 0.2.2
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.2.2"
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 .settings import MCPHubScreen
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.settings import MCPHubScreen
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.settings import MCPServerEditScreen
38
+ from axio_tools_mcp.tui import MCPServerEditScreen
39
39
 
40
40
  assert MCPServerEditScreen is not None
@@ -47,7 +47,7 @@ wheels = [
47
47
 
48
48
  [[package]]
49
49
  name = "axio-tools-mcp"
50
- version = "0.2.2"
50
+ version = "0.2.3"
51
51
  source = { editable = "." }
52
52
  dependencies = [
53
53
  { name = "axio" },
@@ -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