claude-sdk-tutor 0.1.5__tar.gz → 0.1.6__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.
@@ -0,0 +1,50 @@
1
+ # Claude SDK Tutor
2
+
3
+ A terminal-based chat interface for learning programming with Claude. Built with Textual and the Claude Agent SDK, it provides an interactive TUI where Claude acts as a programming tutor rather than simply writing code for you.
4
+
5
+ ## Features
6
+
7
+ - **Tutor Mode**: Claude guides learning through explanations and hints instead of providing complete solutions
8
+ - **Web Search**: Optional web lookup capability (toggle with `/togglewebsearch`)
9
+ - **MCP Servers**: Connect external tools via Model Context Protocol (stdio, SSE, HTTP transports)
10
+ - **Command History**: Navigate previous commands with up/down arrows (persisted across sessions)
11
+ - **Query Interruption**: Cancel running queries with Escape or Ctrl+C
12
+
13
+ ## Setup
14
+
15
+ ```bash
16
+ uv sync
17
+ ```
18
+
19
+ ## Run
20
+
21
+ ```bash
22
+ uv run python app.py
23
+ ```
24
+
25
+ ## Slash Commands
26
+
27
+ - `/help` - Show available commands
28
+ - `/clear` - Clear conversation and reconnect
29
+ - `/tutor` - Toggle tutor mode on/off
30
+ - `/togglewebsearch` - Toggle web search capability
31
+ - `/mcp` - Manage MCP servers (use `/mcp help` for subcommands)
32
+
33
+ ## Architecture
34
+
35
+ ```
36
+ app.py # Main Textual app with UI and command handling
37
+ src/claude/
38
+ claude_agent.py # Claude SDK client creation and streaming
39
+ widgets.py # Custom HistoryInput widget with keybindings
40
+ history.py # Persistent command history
41
+ mcp_config.py # MCP server configuration storage
42
+ mcp_commands.py # /mcp command parsing and handlers
43
+ ```
44
+
45
+ ## Tech Stack
46
+
47
+ - Python 3.13+
48
+ - Textual (TUI framework)
49
+ - Claude Agent SDK
50
+ - uv (package manager)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-sdk-tutor
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: Add your description here
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.13
@@ -15,6 +15,15 @@ Description-Content-Type: text/markdown
15
15
 
16
16
  A terminal-based programming tutor powered by Claude. Built with Textual and the Claude Agent SDK.
17
17
 
18
+ ## Features
19
+
20
+ - **Tutor Mode** - Claude guides your learning instead of writing code for you
21
+ - **Web Search** - Optional online lookup capability
22
+ - **MCP Servers** - Extend Claude with external tools via Model Context Protocol
23
+ - **Command History** - Navigate previous commands with up/down arrows (persisted across sessions)
24
+ - **Query Interruption** - Cancel long-running queries with Escape or Ctrl+C
25
+ - **File Access** - Claude can read files in your codebase to provide contextual help
26
+
18
27
  ## Overview
19
28
 
20
29
  Claude Tutor is a TUI (Terminal User Interface) application designed to help you learn programming concepts. Unlike a typical coding assistant, Claude Tutor focuses on teaching rather than writing code for you. It will:
@@ -58,6 +67,16 @@ Type your programming questions in the input field and press Enter to send. Clau
58
67
  - **Grey** - Tool usage (when Claude reads files in your codebase)
59
68
  - **Green** - Slash command feedback
60
69
 
70
+ ### Keyboard Shortcuts
71
+
72
+ | Key | Action |
73
+ |-----|--------|
74
+ | `Enter` | Send message |
75
+ | `Up` / `Down` | Navigate command history |
76
+ | `Escape` or `Ctrl+C` | Cancel running query |
77
+
78
+ Command history is automatically saved between sessions.
79
+
61
80
  ## Slash Commands
62
81
 
63
82
  | Command | Description |
@@ -66,6 +85,45 @@ Type your programming questions in the input field and press Enter to send. Clau
66
85
  | `/clear` | Clears the conversation history and starts fresh. Your settings are preserved. |
67
86
  | `/tutor` | Toggles tutor mode on/off. When on (default), Claude acts as a teacher. When off, Claude responds normally without the tutoring constraints. |
68
87
  | `/togglewebsearch` | Toggles web search on/off. When on, Claude can use WebSearch and WebFetch tools to look up information online. Disabled by default. |
88
+ | `/mcp` | Manage MCP servers. Use `/mcp help` for detailed subcommands. |
89
+
90
+ ## MCP Server Support
91
+
92
+ Claude Tutor supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, allowing you to extend Claude's capabilities with external tools.
93
+
94
+ ### MCP Commands
95
+
96
+ | Command | Description |
97
+ |---------|-------------|
98
+ | `/mcp list` | List all configured servers with connection status |
99
+ | `/mcp test [name]` | Test MCP server connections |
100
+ | `/mcp add` | Add a new server (interactive wizard) |
101
+ | `/mcp add <name> <type> <cmd\|url> [args]` | Add server directly |
102
+ | `/mcp remove <name>` | Remove a server |
103
+ | `/mcp enable <name>` | Enable a disabled server |
104
+ | `/mcp disable <name>` | Disable a server without removing it |
105
+ | `/mcp status [name]` | Show server configuration details |
106
+
107
+ ### Server Types
108
+
109
+ - **stdio** - Local process that communicates via stdin/stdout (e.g., `npx` packages)
110
+ - **sse** - Server-Sent Events endpoint
111
+ - **http** - HTTP endpoint
112
+
113
+ ### Example: Adding an MCP Server
114
+
115
+ ```
116
+ /mcp add filesystem stdio npx -y @anthropic/mcp-filesystem
117
+ ```
118
+
119
+ Or use the interactive wizard:
120
+ ```
121
+ /mcp add
122
+ ```
123
+
124
+ After adding or modifying servers, use `/clear` to reconnect with the updated configuration.
125
+
126
+ MCP server configurations are persisted to `~/.local/share/claude-sdk-tutor/mcp_servers.json`.
69
127
 
70
128
  ## Tech Stack
71
129
 
@@ -2,6 +2,15 @@
2
2
 
3
3
  A terminal-based programming tutor powered by Claude. Built with Textual and the Claude Agent SDK.
4
4
 
5
+ ## Features
6
+
7
+ - **Tutor Mode** - Claude guides your learning instead of writing code for you
8
+ - **Web Search** - Optional online lookup capability
9
+ - **MCP Servers** - Extend Claude with external tools via Model Context Protocol
10
+ - **Command History** - Navigate previous commands with up/down arrows (persisted across sessions)
11
+ - **Query Interruption** - Cancel long-running queries with Escape or Ctrl+C
12
+ - **File Access** - Claude can read files in your codebase to provide contextual help
13
+
5
14
  ## Overview
6
15
 
7
16
  Claude Tutor is a TUI (Terminal User Interface) application designed to help you learn programming concepts. Unlike a typical coding assistant, Claude Tutor focuses on teaching rather than writing code for you. It will:
@@ -45,6 +54,16 @@ Type your programming questions in the input field and press Enter to send. Clau
45
54
  - **Grey** - Tool usage (when Claude reads files in your codebase)
46
55
  - **Green** - Slash command feedback
47
56
 
57
+ ### Keyboard Shortcuts
58
+
59
+ | Key | Action |
60
+ |-----|--------|
61
+ | `Enter` | Send message |
62
+ | `Up` / `Down` | Navigate command history |
63
+ | `Escape` or `Ctrl+C` | Cancel running query |
64
+
65
+ Command history is automatically saved between sessions.
66
+
48
67
  ## Slash Commands
49
68
 
50
69
  | Command | Description |
@@ -53,6 +72,45 @@ Type your programming questions in the input field and press Enter to send. Clau
53
72
  | `/clear` | Clears the conversation history and starts fresh. Your settings are preserved. |
54
73
  | `/tutor` | Toggles tutor mode on/off. When on (default), Claude acts as a teacher. When off, Claude responds normally without the tutoring constraints. |
55
74
  | `/togglewebsearch` | Toggles web search on/off. When on, Claude can use WebSearch and WebFetch tools to look up information online. Disabled by default. |
75
+ | `/mcp` | Manage MCP servers. Use `/mcp help` for detailed subcommands. |
76
+
77
+ ## MCP Server Support
78
+
79
+ Claude Tutor supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, allowing you to extend Claude's capabilities with external tools.
80
+
81
+ ### MCP Commands
82
+
83
+ | Command | Description |
84
+ |---------|-------------|
85
+ | `/mcp list` | List all configured servers with connection status |
86
+ | `/mcp test [name]` | Test MCP server connections |
87
+ | `/mcp add` | Add a new server (interactive wizard) |
88
+ | `/mcp add <name> <type> <cmd\|url> [args]` | Add server directly |
89
+ | `/mcp remove <name>` | Remove a server |
90
+ | `/mcp enable <name>` | Enable a disabled server |
91
+ | `/mcp disable <name>` | Disable a server without removing it |
92
+ | `/mcp status [name]` | Show server configuration details |
93
+
94
+ ### Server Types
95
+
96
+ - **stdio** - Local process that communicates via stdin/stdout (e.g., `npx` packages)
97
+ - **sse** - Server-Sent Events endpoint
98
+ - **http** - HTTP endpoint
99
+
100
+ ### Example: Adding an MCP Server
101
+
102
+ ```
103
+ /mcp add filesystem stdio npx -y @anthropic/mcp-filesystem
104
+ ```
105
+
106
+ Or use the interactive wizard:
107
+ ```
108
+ /mcp add
109
+ ```
110
+
111
+ After adding or modifying servers, use `/clear` to reconnect with the updated configuration.
112
+
113
+ MCP server configurations are persisted to `~/.local/share/claude-sdk-tutor/mcp_servers.json`.
56
114
 
57
115
  ## Tech Stack
58
116
 
@@ -0,0 +1,359 @@
1
+ import json
2
+
3
+ from claude_agent_sdk import (
4
+ AssistantMessage,
5
+ ClaudeAgentOptions,
6
+ ResultMessage,
7
+ SystemMessage,
8
+ query,
9
+ )
10
+ from rich.markdown import Markdown as RichMarkdown
11
+ from rich.panel import Panel
12
+ from textual.app import App, ComposeResult
13
+ from textual.containers import Vertical
14
+ from textual.widgets import Static, Footer, Input, RichLog, LoadingIndicator
15
+
16
+ from claude.claude_agent import (
17
+ connect_client,
18
+ create_claude_client,
19
+ stream_helpful_claude,
20
+ )
21
+ from claude.history import CommandHistory
22
+ from claude.mcp_commands import McpAsyncCommand, McpCommandHandler
23
+ from claude.mcp_config import McpConfigManager
24
+ from claude.widgets import HistoryInput
25
+
26
+
27
+ class MyApp(App):
28
+ def __init__(self):
29
+ super().__init__()
30
+ self.tutor_mode = True
31
+ self.web_search_enabled = False
32
+ self.mcp_config = McpConfigManager()
33
+ self.mcp_handler = McpCommandHandler(self.mcp_config)
34
+ self.mcp_add_state: dict | None = None # For interactive /mcp add wizard
35
+ self.client = self._create_client()
36
+ self.history = CommandHistory()
37
+ self._query_running: bool = False # Track if a query is active
38
+
39
+ def _create_client(self):
40
+ """Create a new Claude client with current settings."""
41
+ return create_claude_client(
42
+ tutor_mode=self.tutor_mode,
43
+ web_search=self.web_search_enabled,
44
+ mcp_servers=self.mcp_config.get_enabled_servers_for_sdk(),
45
+ )
46
+
47
+ CSS = """
48
+ #main {
49
+ height: 100%;
50
+ }
51
+ Input {
52
+ height: auto;
53
+ margin-top: 1;
54
+ margin-left: 3;
55
+ margin-right: 3;
56
+ margin-bottom: 1;
57
+ }
58
+ #header {
59
+ content-align: center middle;
60
+ width: 100%;
61
+ margin-top: 1;
62
+ margin-bottom: 1;
63
+ height: auto;
64
+ }
65
+ RichLog {
66
+ background: $boost;
67
+ margin-left: 3;
68
+ margin-right: 3;
69
+ height: 1fr;
70
+ }
71
+ LoadingIndicator {
72
+ height: auto;
73
+ margin-left: 3;
74
+ margin-right: 3;
75
+ }
76
+ """
77
+
78
+ def compose(self) -> ComposeResult:
79
+ with Vertical(id="main"):
80
+ yield Static("Welcome to claude SDK tutor!", id="header")
81
+ yield RichLog(markup=True, highlight=True)
82
+ yield LoadingIndicator(id="spinner")
83
+ yield HistoryInput(history=self.history)
84
+ yield Footer()
85
+
86
+ async def on_mount(self) -> None:
87
+ self.query_one("#spinner", LoadingIndicator).display = False
88
+ await connect_client(self.client)
89
+
90
+ def write_user_message(self, message: str) -> None:
91
+ log = self.query_one(RichLog)
92
+ log.write(Panel(RichMarkdown(message), title="You", border_style="dodger_blue1"))
93
+
94
+ def write_system_message(self, message: str) -> None:
95
+ log = self.query_one(RichLog)
96
+ log.write(Panel(RichMarkdown(message), title="Claude", border_style="red"))
97
+
98
+ def write_tool_message(self, name: str, input: dict) -> None:
99
+ log = self.query_one(RichLog)
100
+ input_str = json.dumps(input, indent=2)
101
+ content = f"**{name}**\n```json\n{input_str}\n```"
102
+ log.write(Panel(RichMarkdown(content), title="Tool", border_style="grey50"))
103
+
104
+ def write_slash_message(self, message: str) -> None:
105
+ log = self.query_one(RichLog)
106
+ log.write(Panel(RichMarkdown(message), title="Slash", border_style="green"))
107
+
108
+ def on_input_submitted(self, event: Input.Submitted) -> None:
109
+ command = event.value.strip()
110
+ self.query_one(HistoryInput).value = ""
111
+ if command:
112
+ self.history.add(command)
113
+
114
+ # Handle interactive MCP add wizard
115
+ if self.mcp_add_state is not None:
116
+ if command.lower() == "/cancel":
117
+ self.mcp_add_state = None
118
+ self.write_slash_message("Cancelled MCP server setup.")
119
+ return
120
+ self._handle_mcp_add_step(command)
121
+ return
122
+
123
+ if command == "/clear":
124
+ self.run_worker(self.clear_conversation())
125
+ return
126
+ if command == "/tutor":
127
+ self.run_worker(self.toggle_tutor_mode())
128
+ return
129
+ if command == "/togglewebsearch":
130
+ self.run_worker(self.toggle_web_search())
131
+ return
132
+ if command == "/help":
133
+ self.show_help()
134
+ return
135
+ if command.lower().startswith("/mcp"):
136
+ self._handle_mcp_command(command)
137
+ return
138
+ self.write_user_message(event.value)
139
+ self.query_one("#spinner", LoadingIndicator).display = True
140
+ self._query_running = True
141
+ self.run_worker(self.get_response(event.value))
142
+
143
+ async def clear_conversation(self) -> None:
144
+ self.query_one(RichLog).clear()
145
+ self.client = self._create_client()
146
+ await connect_client(self.client)
147
+ self.write_slash_message("Context cleared")
148
+
149
+ async def toggle_tutor_mode(self) -> None:
150
+ self.tutor_mode = not self.tutor_mode
151
+ self.query_one(RichLog).clear()
152
+ self.client = self._create_client()
153
+ await connect_client(self.client)
154
+ status = "on" if self.tutor_mode else "off"
155
+ self.write_slash_message(f"Tutor mode {status}")
156
+
157
+ async def toggle_web_search(self) -> None:
158
+ self.web_search_enabled = not self.web_search_enabled
159
+ self.query_one(RichLog).clear()
160
+ self.client = self._create_client()
161
+ await connect_client(self.client)
162
+ status = "on" if self.web_search_enabled else "off"
163
+ self.write_slash_message(f"Web search {status}")
164
+
165
+ def show_help(self) -> None:
166
+ help_text = """**Available Commands**
167
+
168
+ - `/help` - Show this help message
169
+ - `/clear` - Clear conversation history and start fresh
170
+ - `/tutor` - Toggle tutor mode on/off (guides learning vs gives code)
171
+ - `/togglewebsearch` - Toggle web search on/off (allows online lookups)
172
+ - `/mcp` - Manage MCP servers (use `/mcp help` for details)"""
173
+ self.write_slash_message(help_text)
174
+
175
+ def _handle_mcp_command(self, command: str) -> None:
176
+ """Handle /mcp commands."""
177
+ result = self.mcp_handler.handle_command(command)
178
+ if result is None:
179
+ # Start interactive add wizard
180
+ self.mcp_add_state = {"step": 0, "name": "", "type": "", "data": {}}
181
+ self.write_slash_message(
182
+ "**Add MCP Server**\n\nEnter server name (or `/cancel` to abort):"
183
+ )
184
+ elif isinstance(result, McpAsyncCommand):
185
+ # Async command needs connection testing
186
+ self.query_one("#spinner", LoadingIndicator).display = True
187
+ self.run_worker(self._test_mcp_connections(result))
188
+ else:
189
+ self.write_slash_message(result)
190
+
191
+ async def _test_mcp_connections(self, cmd: McpAsyncCommand) -> None:
192
+ """Test MCP server connections and display results."""
193
+ try:
194
+ mcp_servers = self.mcp_config.get_enabled_servers_for_sdk()
195
+ if not mcp_servers:
196
+ if cmd.command == "test":
197
+ self.write_slash_message(
198
+ "**MCP Test**\n\nNo enabled servers to test."
199
+ )
200
+ else:
201
+ self.write_slash_message(
202
+ self.mcp_handler.handle_list(cmd.args, connection_status=None)
203
+ )
204
+ return
205
+
206
+ # Build allowed tools for the test
207
+ allowed_tools = [f"mcp__{name}__*" for name in mcp_servers]
208
+
209
+ options = ClaudeAgentOptions(
210
+ mcp_servers=mcp_servers,
211
+ allowed_tools=allowed_tools,
212
+ max_turns=1,
213
+ )
214
+
215
+ connection_status: dict[str, str] = {}
216
+
217
+ # Run a minimal query just to get the init message with MCP status
218
+ async for message in query(prompt="test", options=options):
219
+ if isinstance(message, SystemMessage) and message.subtype == "init":
220
+ mcp_info = message.data.get("mcp_servers", [])
221
+ for server in mcp_info:
222
+ name = server.get("name", "unknown")
223
+ status = server.get("status", "unknown")
224
+ connection_status[name] = status
225
+ break
226
+
227
+ # Display results based on command
228
+ if cmd.command == "test":
229
+ self.write_slash_message(
230
+ self.mcp_handler.handle_test(cmd.args, connection_status)
231
+ )
232
+ else: # list
233
+ self.write_slash_message(
234
+ self.mcp_handler.handle_list(cmd.args, connection_status)
235
+ )
236
+ except Exception as e:
237
+ self.write_slash_message(f"**Error** testing MCP connections: {e}")
238
+ finally:
239
+ self.query_one("#spinner", LoadingIndicator).display = False
240
+
241
+ def _handle_mcp_add_step(self, user_input: str) -> None:
242
+ """Handle a step in the interactive MCP add wizard."""
243
+ state = self.mcp_add_state
244
+ if state is None:
245
+ return
246
+
247
+ step = state["step"]
248
+
249
+ if step == 0:
250
+ # Got server name
251
+ name = user_input.strip()
252
+ if not name:
253
+ self.write_slash_message("**Error**: Name cannot be empty. Try again:")
254
+ return
255
+ if self.mcp_config.get_server(name):
256
+ self.write_slash_message(
257
+ f"**Error**: Server `{name}` already exists. Enter a different name:"
258
+ )
259
+ return
260
+ state["name"] = name
261
+ state["step"] = 1
262
+ self.write_slash_message(
263
+ "Select server type:\n- `stdio` - Local process\n- `sse` - Server-Sent Events\n- `http` - HTTP endpoint"
264
+ )
265
+
266
+ elif step == 1:
267
+ # Got server type
268
+ server_type = user_input.strip().lower()
269
+ if server_type not in ("stdio", "sse", "http"):
270
+ self.write_slash_message(
271
+ f"**Error**: Invalid type `{server_type}`. Enter `stdio`, `sse`, or `http`:"
272
+ )
273
+ return
274
+ state["type"] = server_type
275
+ state["step"] = 2
276
+ if server_type == "stdio":
277
+ self.write_slash_message("Enter command to run (e.g., `npx`):")
278
+ else:
279
+ self.write_slash_message("Enter server URL:")
280
+
281
+ elif step == 2:
282
+ # Got command or URL
283
+ value = user_input.strip()
284
+ if not value:
285
+ self.write_slash_message("**Error**: Value cannot be empty. Try again:")
286
+ return
287
+
288
+ if state["type"] == "stdio":
289
+ state["data"]["command"] = value
290
+ state["step"] = 3
291
+ self.write_slash_message(
292
+ "Enter arguments (space-separated, or leave empty):"
293
+ )
294
+ else:
295
+ # SSE or HTTP - URL provided, we're done
296
+ config = {"type": state["type"], "url": value}
297
+ self.mcp_config.add_server(state["name"], config)
298
+ self.write_slash_message(
299
+ f"**Added** server `{state['name']}` ({state['type']})\n\n"
300
+ "Use `/clear` to reconnect with new MCP servers."
301
+ )
302
+ self.mcp_add_state = None
303
+
304
+ elif step == 3:
305
+ # Got args for stdio command
306
+ args = user_input.strip().split() if user_input.strip() else []
307
+ config = {
308
+ "type": "stdio",
309
+ "command": state["data"]["command"],
310
+ "args": args,
311
+ }
312
+ self.mcp_config.add_server(state["name"], config)
313
+ self.write_slash_message(
314
+ f"**Added** server `{state['name']}` (stdio)\n\n"
315
+ "Use `/clear` to reconnect with new MCP servers."
316
+ )
317
+ self.mcp_add_state = None
318
+
319
+ async def get_response(self, text: str) -> None:
320
+ try:
321
+ async for message in stream_helpful_claude(self.client, text):
322
+ if isinstance(message, AssistantMessage):
323
+ for block in message.content:
324
+ if hasattr(block, "text"):
325
+ self.write_system_message(block.text)
326
+ elif hasattr(block, "name"):
327
+ self.write_tool_message(
328
+ block.name, getattr(block, "input", {})
329
+ )
330
+ elif isinstance(message, ResultMessage):
331
+ pass # Might want to add logging later
332
+ finally:
333
+ self.query_one("#spinner", LoadingIndicator).display = False
334
+ self._query_running = False
335
+
336
+ def action_cancel_query(self) -> None:
337
+ """Interrupt the current running query using SDK interrupt."""
338
+ if self._query_running:
339
+ self.run_worker(self._interrupt_query())
340
+
341
+ async def _interrupt_query(self) -> None:
342
+ """Send interrupt signal to the Claude SDK client."""
343
+ try:
344
+ await self.client.interrupt()
345
+ self.write_slash_message("Query interrupted.")
346
+ except Exception:
347
+ pass # Ignore errors if not connected or no active query
348
+ finally:
349
+ self._query_running = False
350
+ self.query_one("#spinner", LoadingIndicator).display = False
351
+
352
+
353
+ def main():
354
+ app = MyApp()
355
+ app.run()
356
+
357
+
358
+ if __name__ == "__main__":
359
+ main()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-sdk-tutor"
3
- version = "0.1.5"
3
+ version = "0.1.6"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -13,12 +13,18 @@ Never write complete solutions for them. Instead, help them develop the skills t
13
13
 
14
14
 
15
15
  def create_claude_client(
16
- tutor_mode: bool = True, web_search: bool = False
16
+ tutor_mode: bool = True,
17
+ web_search: bool = False,
18
+ mcp_servers: dict | None = None,
17
19
  ) -> ClaudeSDKClient:
18
20
  tools = ["Read", "Glob", "Grep"]
19
21
  if web_search:
20
22
  tools.extend(["WebSearch", "WebFetch"])
21
- options = ClaudeAgentOptions(allowed_tools=tools)
23
+ if mcp_servers:
24
+ # Allow all tools from each configured MCP server
25
+ for server_name in mcp_servers:
26
+ tools.append(f"mcp__{server_name}__*")
27
+ options = ClaudeAgentOptions(allowed_tools=tools, mcp_servers=mcp_servers or {})
22
28
  if tutor_mode:
23
29
  options.system_prompt = TUTOR_SYSTEM_PROMPT
24
30
  return ClaudeSDKClient(options=options)
@@ -0,0 +1,256 @@
1
+ """MCP command handling for slash commands."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from .mcp_config import McpConfigManager, McpServerEntry
6
+
7
+
8
+ @dataclass
9
+ class McpAsyncCommand:
10
+ """Indicates an async command that needs special handling."""
11
+
12
+ command: str
13
+ args: list[str]
14
+
15
+
16
+ class McpCommandHandler:
17
+ """Handles /mcp slash commands."""
18
+
19
+ def __init__(self, config_manager: McpConfigManager):
20
+ self.config = config_manager
21
+
22
+ def parse_command(self, command: str) -> tuple[str, list[str]]:
23
+ """Parse /mcp command into subcommand and args.
24
+
25
+ Example: "/mcp add myserver" -> ("add", ["myserver"])
26
+ """
27
+ parts = command.strip().split()
28
+ # Remove /mcp prefix if present
29
+ if parts and parts[0].lower() == "/mcp":
30
+ parts = parts[1:]
31
+
32
+ if not parts:
33
+ return "help", []
34
+
35
+ subcommand = parts[0].lower()
36
+ args = parts[1:]
37
+ return subcommand, args
38
+
39
+ def handle_command(self, command: str) -> str | None | McpAsyncCommand:
40
+ """Handle a /mcp command.
41
+
42
+ Returns:
43
+ str: Markdown response to display
44
+ None: Trigger interactive mode
45
+ McpAsyncCommand: Command needs async handling
46
+ """
47
+ subcommand, args = self.parse_command(command)
48
+
49
+ # Commands that need async handling (connection testing)
50
+ if subcommand in ("test", "list"):
51
+ return McpAsyncCommand(command=subcommand, args=args)
52
+
53
+ handlers = {
54
+ "add": self.handle_add,
55
+ "remove": self.handle_remove,
56
+ "enable": self.handle_enable,
57
+ "disable": self.handle_disable,
58
+ "status": self.handle_status,
59
+ "help": self.handle_help,
60
+ }
61
+
62
+ handler = handlers.get(subcommand, self.handle_help)
63
+ return handler(args)
64
+
65
+ def handle_list(
66
+ self, _args: list[str], connection_status: dict[str, str] | None = None
67
+ ) -> str:
68
+ """List all configured MCP servers with optional connection status."""
69
+ servers = self.config.list_servers()
70
+
71
+ if not servers:
72
+ return "**MCP Servers**\n\nNo servers configured. Use `/mcp add` to add one."
73
+
74
+ lines = ["**MCP Servers**\n"]
75
+ lines.append("| Name | Type | Enabled | Connection | Target |")
76
+ lines.append("|------|------|---------|------------|--------|")
77
+
78
+ for server in servers:
79
+ server_type = server.config.get("type", "stdio")
80
+ enabled = "yes" if server.enabled else "no"
81
+ target = self._get_target_display(server)
82
+
83
+ if connection_status and server.name in connection_status:
84
+ conn = connection_status[server.name]
85
+ elif not server.enabled:
86
+ conn = "—"
87
+ else:
88
+ conn = "unknown"
89
+
90
+ lines.append(
91
+ f"| {server.name} | {server_type} | {enabled} | {conn} | {target} |"
92
+ )
93
+
94
+ return "\n".join(lines)
95
+
96
+ def handle_test(
97
+ self, args: list[str], connection_status: dict[str, str]
98
+ ) -> str:
99
+ """Format test results for MCP server connections."""
100
+ if not connection_status:
101
+ return "**MCP Test**\n\nNo enabled servers to test."
102
+
103
+ # Filter to specific server if provided
104
+ if args:
105
+ name = args[0]
106
+ if name not in connection_status:
107
+ server = self.config.get_server(name)
108
+ if not server:
109
+ return f"**Error**: Server `{name}` not found."
110
+ if not server.enabled:
111
+ return f"**Error**: Server `{name}` is disabled."
112
+ return f"**Error**: Server `{name}` was not tested."
113
+ connection_status = {name: connection_status[name]}
114
+
115
+ lines = ["**MCP Connection Test**\n"]
116
+
117
+ connected = 0
118
+ failed = 0
119
+ for name, status in connection_status.items():
120
+ if status == "connected":
121
+ lines.append(f"- `{name}`: **connected**")
122
+ connected += 1
123
+ else:
124
+ lines.append(f"- `{name}`: **{status}**")
125
+ failed += 1
126
+
127
+ lines.append(f"\n**Summary**: {connected} connected, {failed} failed")
128
+ return "\n".join(lines)
129
+
130
+ def _get_target_display(self, server: McpServerEntry) -> str:
131
+ """Get display string for server target."""
132
+ config = server.config
133
+ server_type = config.get("type", "")
134
+
135
+ if server_type == "stdio":
136
+ cmd = config.get("command", "")
137
+ args = config.get("args", [])
138
+ if args:
139
+ return f"{cmd} {' '.join(args[:2])}{'...' if len(args) > 2 else ''}"
140
+ return cmd
141
+ elif server_type in ("sse", "http"):
142
+ return config.get("url", "")
143
+ return "—"
144
+
145
+ def handle_add(self, args: list[str]) -> str | None:
146
+ """Handle /mcp add command.
147
+
148
+ If args provided: /mcp add <name> <type> <command/url> [args...]
149
+ If no args: return None to trigger interactive mode
150
+ """
151
+ if not args:
152
+ return None # Trigger interactive mode
153
+
154
+ if len(args) < 3:
155
+ return "**Error**: Usage: `/mcp add <name> <type> <command|url> [args...]`"
156
+
157
+ name = args[0]
158
+ server_type = args[1].lower()
159
+
160
+ if server_type not in ("stdio", "sse", "http"):
161
+ return f"**Error**: Invalid type `{server_type}`. Must be stdio, sse, or http."
162
+
163
+ if self.config.get_server(name):
164
+ return f"**Error**: Server `{name}` already exists."
165
+
166
+ if server_type == "stdio":
167
+ command = args[2]
168
+ cmd_args = args[3:] if len(args) > 3 else []
169
+ config = {"type": "stdio", "command": command, "args": cmd_args}
170
+ else:
171
+ url = args[2]
172
+ config = {"type": server_type, "url": url}
173
+
174
+ self.config.add_server(name, config)
175
+ return f"**Added** server `{name}` ({server_type})"
176
+
177
+ def handle_remove(self, args: list[str]) -> str:
178
+ """Handle /mcp remove <name> command."""
179
+ if not args:
180
+ return "**Error**: Usage: `/mcp remove <name>`"
181
+
182
+ name = args[0]
183
+ if self.config.remove_server(name):
184
+ return f"**Removed** server `{name}`"
185
+ return f"**Error**: Server `{name}` not found."
186
+
187
+ def handle_enable(self, args: list[str]) -> str:
188
+ """Handle /mcp enable <name> command."""
189
+ if not args:
190
+ return "**Error**: Usage: `/mcp enable <name>`"
191
+
192
+ name = args[0]
193
+ if self.config.enable_server(name):
194
+ return f"**Enabled** server `{name}`"
195
+ return f"**Error**: Server `{name}` not found."
196
+
197
+ def handle_disable(self, args: list[str]) -> str:
198
+ """Handle /mcp disable <name> command."""
199
+ if not args:
200
+ return "**Error**: Usage: `/mcp disable <name>`"
201
+
202
+ name = args[0]
203
+ if self.config.disable_server(name):
204
+ return f"**Disabled** server `{name}`"
205
+ return f"**Error**: Server `{name}` not found."
206
+
207
+ def handle_status(self, args: list[str]) -> str:
208
+ """Handle /mcp status [name] command."""
209
+ if not args:
210
+ # Show summary status
211
+ servers = self.config.list_servers()
212
+ enabled = sum(1 for s in servers if s.enabled)
213
+ return f"**MCP Status**: {enabled}/{len(servers)} servers enabled"
214
+
215
+ name = args[0]
216
+ server = self.config.get_server(name)
217
+ if not server:
218
+ return f"**Error**: Server `{name}` not found."
219
+
220
+ lines = [f"**Server: {name}**\n"]
221
+ lines.append(f"- **Type**: {server.config.get('type', 'unknown')}")
222
+ lines.append(f"- **Status**: {'enabled' if server.enabled else 'disabled'}")
223
+
224
+ server_type = server.config.get("type", "")
225
+ if server_type == "stdio":
226
+ cmd = server.config.get("command", "")
227
+ args_list = server.config.get("args", [])
228
+ full_cmd = f"{cmd} {' '.join(args_list)}".strip()
229
+ lines.append(f"- **Command**: `{full_cmd}`")
230
+ env = server.config.get("env", {})
231
+ if env:
232
+ lines.append(f"- **Env vars**: {', '.join(env.keys())}")
233
+ else:
234
+ url = server.config.get("url", "")
235
+ lines.append(f"- **URL**: {url}")
236
+
237
+ return "\n".join(lines)
238
+
239
+ def handle_help(self, _args: list[str]) -> str:
240
+ """Show help for /mcp commands."""
241
+ return """**MCP Server Commands**
242
+
243
+ - `/mcp list` - List all configured servers with connection status
244
+ - `/mcp test [name]` - Test MCP server connections
245
+ - `/mcp add` - Add a new server (interactive)
246
+ - `/mcp add <name> <type> <cmd|url> [args]` - Add server directly
247
+ - `/mcp remove <name>` - Remove a server
248
+ - `/mcp enable <name>` - Enable a server
249
+ - `/mcp disable <name>` - Disable a server
250
+ - `/mcp status [name]` - Show server config details
251
+ - `/mcp help` - Show this help
252
+
253
+ **Server Types**
254
+ - `stdio` - Local process (command + args)
255
+ - `sse` - Server-Sent Events endpoint (URL)
256
+ - `http` - HTTP endpoint (URL)"""
@@ -0,0 +1,140 @@
1
+ """MCP Server configuration management."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class McpServerEntry:
13
+ """An MCP server configuration entry."""
14
+
15
+ name: str
16
+ enabled: bool
17
+ config: dict[str, Any]
18
+
19
+
20
+ @dataclass
21
+ class McpConfig:
22
+ """Full MCP configuration."""
23
+
24
+ version: int = 1
25
+ servers: dict[str, dict[str, Any]] = field(default_factory=dict)
26
+
27
+
28
+ class McpConfigManager:
29
+ """Manages MCP server configurations with persistent storage."""
30
+
31
+ CONFIG_DIR = Path.home() / ".local" / "share" / "claude-sdk-tutor"
32
+ CONFIG_FILE = CONFIG_DIR / "mcp_servers.json"
33
+
34
+ def __init__(self):
35
+ self._config: McpConfig = McpConfig()
36
+ self._load()
37
+
38
+ def _load(self) -> None:
39
+ """Load configuration from disk."""
40
+ if self.CONFIG_FILE.exists():
41
+ try:
42
+ with open(self.CONFIG_FILE) as f:
43
+ data = json.load(f)
44
+ self._config = McpConfig(
45
+ version=data.get("version", 1),
46
+ servers=data.get("servers", {}),
47
+ )
48
+ except (json.JSONDecodeError, OSError):
49
+ self._config = McpConfig()
50
+ else:
51
+ self._config = McpConfig()
52
+
53
+ def _save(self) -> None:
54
+ """Save configuration to disk."""
55
+ self.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
56
+ with open(self.CONFIG_FILE, "w") as f:
57
+ json.dump(
58
+ {"version": self._config.version, "servers": self._config.servers},
59
+ f,
60
+ indent=2,
61
+ )
62
+
63
+ def list_servers(self) -> list[McpServerEntry]:
64
+ """List all configured servers."""
65
+ entries = []
66
+ for name, data in self._config.servers.items():
67
+ entries.append(
68
+ McpServerEntry(
69
+ name=name,
70
+ enabled=data.get("enabled", True),
71
+ config=data.get("config", {}),
72
+ )
73
+ )
74
+ return entries
75
+
76
+ def get_server(self, name: str) -> McpServerEntry | None:
77
+ """Get a specific server by name."""
78
+ if name not in self._config.servers:
79
+ return None
80
+ data = self._config.servers[name]
81
+ return McpServerEntry(
82
+ name=name,
83
+ enabled=data.get("enabled", True),
84
+ config=data.get("config", {}),
85
+ )
86
+
87
+ def add_server(self, name: str, config: dict[str, Any]) -> None:
88
+ """Add a new server configuration."""
89
+ self._config.servers[name] = {"enabled": True, "config": config}
90
+ self._save()
91
+
92
+ def remove_server(self, name: str) -> bool:
93
+ """Remove a server configuration. Returns True if removed."""
94
+ if name in self._config.servers:
95
+ del self._config.servers[name]
96
+ self._save()
97
+ return True
98
+ return False
99
+
100
+ def enable_server(self, name: str) -> bool:
101
+ """Enable a server. Returns True if server exists."""
102
+ if name in self._config.servers:
103
+ self._config.servers[name]["enabled"] = True
104
+ self._save()
105
+ return True
106
+ return False
107
+
108
+ def disable_server(self, name: str) -> bool:
109
+ """Disable a server. Returns True if server exists."""
110
+ if name in self._config.servers:
111
+ self._config.servers[name]["enabled"] = False
112
+ self._save()
113
+ return True
114
+ return False
115
+
116
+ def _expand_env_vars(self, value: Any) -> Any:
117
+ """Recursively expand environment variables in config values."""
118
+ if isinstance(value, str):
119
+ # Match ${VAR_NAME} pattern
120
+ pattern = r"\$\{([^}]+)\}"
121
+ matches = re.findall(pattern, value)
122
+ result = value
123
+ for var_name in matches:
124
+ env_value = os.environ.get(var_name, "")
125
+ result = result.replace(f"${{{var_name}}}", env_value)
126
+ return result
127
+ elif isinstance(value, dict):
128
+ return {k: self._expand_env_vars(v) for k, v in value.items()}
129
+ elif isinstance(value, list):
130
+ return [self._expand_env_vars(item) for item in value]
131
+ return value
132
+
133
+ def get_enabled_servers_for_sdk(self) -> dict[str, dict[str, Any]]:
134
+ """Get enabled servers in SDK-compatible format with env vars expanded."""
135
+ result = {}
136
+ for name, data in self._config.servers.items():
137
+ if data.get("enabled", True):
138
+ config = data.get("config", {})
139
+ result[name] = self._expand_env_vars(config)
140
+ return result
@@ -10,6 +10,8 @@ class HistoryInput(Input):
10
10
  BINDINGS = [
11
11
  Binding("up", "history_previous", "Previous command", show=False),
12
12
  Binding("down", "history_next", "Next command", show=False),
13
+ Binding("escape", "app.cancel_query", "Cancel", show=False),
14
+ Binding("ctrl+c", "app.cancel_query", "Cancel", show=False),
13
15
  ]
14
16
 
15
17
  def __init__(self, history: CommandHistory, **kwargs):
@@ -206,7 +206,7 @@ wheels = [
206
206
 
207
207
  [[package]]
208
208
  name = "claude-sdk-tutor"
209
- version = "0.1.4"
209
+ version = "0.1.5"
210
210
  source = { editable = "." }
211
211
  dependencies = [
212
212
  { name = "claude-agent-sdk" },
@@ -1,22 +0,0 @@
1
- # Personal Claude
2
-
3
- A TUI application built with Textual and the Claude Agent SDK.
4
-
5
- ## Setup
6
-
7
- ```bash
8
- uv sync
9
- ```
10
-
11
- ## Run
12
-
13
- ```bash
14
- uv run python main.py
15
- ```
16
-
17
- ## Tech Stack
18
-
19
- - Python 3.13+
20
- - Textual (TUI framework)
21
- - Claude Agent SDK
22
- - uv (package manager)
@@ -1,171 +0,0 @@
1
- import json
2
-
3
- from claude_agent_sdk import AssistantMessage, ResultMessage
4
- from rich.markdown import Markdown as RichMarkdown
5
- from rich.panel import Panel
6
- from textual.app import App, ComposeResult
7
- from textual.containers import Vertical
8
- from textual.widgets import Static, Footer, Input, RichLog, LoadingIndicator
9
-
10
- from claude.claude_agent import (
11
- connect_client,
12
- create_claude_client,
13
- stream_helpful_claude,
14
- )
15
- from claude.history import CommandHistory
16
- from claude.widgets import HistoryInput
17
-
18
-
19
- class MyApp(App):
20
- def __init__(self):
21
- super().__init__()
22
- self.tutor_mode = True
23
- self.web_search_enabled = False
24
- self.client = create_claude_client(
25
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
26
- )
27
- self.history = CommandHistory()
28
-
29
- CSS = """
30
- #main {
31
- height: 100%;
32
- }
33
- Input {
34
- height: auto;
35
- margin-top: 1;
36
- margin-left: 3;
37
- margin-right: 3;
38
- margin-bottom: 1;
39
- }
40
- #header {
41
- content-align: center middle;
42
- width: 100%;
43
- margin-top: 1;
44
- margin-bottom: 1;
45
- height: auto;
46
- }
47
- RichLog {
48
- background: $boost;
49
- margin-left: 3;
50
- margin-right: 3;
51
- height: 1fr;
52
- }
53
- LoadingIndicator {
54
- height: auto;
55
- margin-left: 3;
56
- margin-right: 3;
57
- }
58
- """
59
-
60
- def compose(self) -> ComposeResult:
61
- with Vertical(id="main"):
62
- yield Static("Welcome to claude SDK tutor!", id="header")
63
- yield RichLog(markup=True, highlight=True)
64
- yield LoadingIndicator(id="spinner")
65
- yield HistoryInput(history=self.history)
66
- yield Footer()
67
-
68
- async def on_mount(self) -> None:
69
- self.query_one("#spinner", LoadingIndicator).display = False
70
- await connect_client(self.client)
71
-
72
- def write_user_message(self, message: str) -> None:
73
- log = self.query_one(RichLog)
74
- log.write(Panel(RichMarkdown(message), title="You", border_style="dodger_blue1"))
75
-
76
- def write_system_message(self, message: str) -> None:
77
- log = self.query_one(RichLog)
78
- log.write(Panel(RichMarkdown(message), title="Claude", border_style="red"))
79
-
80
- def write_tool_message(self, name: str, input: dict) -> None:
81
- log = self.query_one(RichLog)
82
- input_str = json.dumps(input, indent=2)
83
- content = f"**{name}**\n```json\n{input_str}\n```"
84
- log.write(Panel(RichMarkdown(content), title="Tool", border_style="grey50"))
85
-
86
- def write_slash_message(self, message: str) -> None:
87
- log = self.query_one(RichLog)
88
- log.write(Panel(RichMarkdown(message), title="Slash", border_style="green"))
89
-
90
- def on_input_submitted(self, event: Input.Submitted) -> None:
91
- command = event.value.strip()
92
- self.query_one(HistoryInput).value = ""
93
- if command:
94
- self.history.add(command)
95
- if command == "/clear":
96
- self.run_worker(self.clear_conversation())
97
- return
98
- if command == "/tutor":
99
- self.run_worker(self.toggle_tutor_mode())
100
- return
101
- if command == "/togglewebsearch":
102
- self.run_worker(self.toggle_web_search())
103
- return
104
- if command == "/help":
105
- self.show_help()
106
- return
107
- self.write_user_message(event.value)
108
- self.query_one("#spinner", LoadingIndicator).display = True
109
- self.run_worker(self.get_response(event.value))
110
-
111
- async def clear_conversation(self) -> None:
112
- self.query_one(RichLog).clear()
113
- self.client = create_claude_client(
114
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
115
- )
116
- await connect_client(self.client)
117
- self.write_slash_message("Context cleared")
118
-
119
- async def toggle_tutor_mode(self) -> None:
120
- self.tutor_mode = not self.tutor_mode
121
- self.query_one(RichLog).clear()
122
- self.client = create_claude_client(
123
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
124
- )
125
- await connect_client(self.client)
126
- status = "on" if self.tutor_mode else "off"
127
- self.write_slash_message(f"Tutor mode {status}")
128
-
129
- async def toggle_web_search(self) -> None:
130
- self.web_search_enabled = not self.web_search_enabled
131
- self.query_one(RichLog).clear()
132
- self.client = create_claude_client(
133
- tutor_mode=self.tutor_mode, web_search=self.web_search_enabled
134
- )
135
- await connect_client(self.client)
136
- status = "on" if self.web_search_enabled else "off"
137
- self.write_slash_message(f"Web search {status}")
138
-
139
- def show_help(self) -> None:
140
- help_text = """**Available Commands**
141
-
142
- - `/help` - Show this help message
143
- - `/clear` - Clear conversation history and start fresh
144
- - `/tutor` - Toggle tutor mode on/off (guides learning vs gives code)
145
- - `/togglewebsearch` - Toggle web search on/off (allows online lookups)"""
146
- self.write_slash_message(help_text)
147
-
148
- async def get_response(self, text: str) -> None:
149
- try:
150
- async for message in stream_helpful_claude(self.client, text):
151
- if isinstance(message, AssistantMessage):
152
- for block in message.content:
153
- if hasattr(block, "text"):
154
- self.write_system_message(block.text)
155
- elif hasattr(block, "name"):
156
- self.write_tool_message(
157
- block.name, getattr(block, "input", {})
158
- )
159
- elif isinstance(message, ResultMessage):
160
- pass # Might want to add logging later
161
- finally:
162
- self.query_one("#spinner", LoadingIndicator).display = False
163
-
164
-
165
- def main():
166
- app = MyApp()
167
- app.run()
168
-
169
-
170
- if __name__ == "__main__":
171
- main()