Skip to content

Commit 57089db

Browse files
authored
Merge pull request #5 from Zsailer/mcp-server
Automatically enable Jupyter Server Docs MCP with this persona
2 parents 14a37fa + a229db6 commit 57089db

File tree

3 files changed

+619
-117
lines changed

3 files changed

+619
-117
lines changed

README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
# Jupyter AI Claude Code
22

3-
Jupyter AI integration with Claude Code.
3+
Jupyter AI integration with Claude Code persona for enhanced development capabilities.
4+
5+
## Features
6+
7+
- **Claude Code Integration**: Full Claude Code persona for Jupyter AI
8+
- **Development Tools**: Access to Claude Code's built-in development tools
9+
- **Seamless Integration**: Works with existing Jupyter AI workflow
10+
- **Template Management**: Interactive task progress tracking and updates
411

512
## Setup
613

@@ -39,7 +46,14 @@ This will:
3946
pixi run start
4047
```
4148

42-
This will start JupyterLab with the Jupyter AI extension and this package available.
49+
This will start JupyterLab with the Jupyter AI extension and Claude Code persona available.
50+
51+
### Using Claude Code Persona
52+
53+
1. Open JupyterLab
54+
2. Open the Jupyter AI chat panel
55+
3. Select "Claude" persona
56+
4. Interact with Claude Code's development tools
4357

4458
### Build the Package
4559

@@ -57,6 +71,7 @@ The package source code is located in `src/jupyter_ai_claude_code/`.
5771

5872
- **JupyterLab**: Latest stable version from conda-forge
5973
- **Jupyter AI**: Version 3.0.0b5 from PyPI
74+
- **Claude Code SDK**: For Claude Code integration
6075
- **Python**: >=3.8
6176

6277
## License
Lines changed: 178 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,66 @@
11
import os
2-
from typing import Dict, Any, List, Optional, AsyncIterator
2+
from typing import Dict, List, AsyncIterator
3+
import functools
34

45
from jupyter_ai_persona_manager import BasePersona, PersonaDefaults
56
from jupyterlab_chat.models import Message
67

7-
from claude_code_sdk import (
8-
query, ClaudeCodeOptions,
9-
Message, SystemMessage, AssistantMessage, ResultMessage,
10-
TextBlock, ToolUseBlock
11-
)
12-
13-
14-
OMIT_INPUT_ARGS = ['content']
15-
16-
TOOL_PARAM_MAPPING = {
17-
'Task': 'description',
18-
'Bash': 'command',
19-
'Glob': 'pattern',
20-
'Grep': 'pattern',
21-
'LS': 'path',
22-
'Read': 'file_path',
23-
'Edit': 'file_path',
24-
'MultiEdit': 'file_path',
25-
'Write': 'file_path',
26-
'NotebookRead': 'notebook_path',
27-
'NotebookWrite': 'notebook_path',
28-
'WebFetch': 'url',
29-
'WebSearch': 'query',
30-
}
31-
32-
# Path to avatar file in this package
33-
AVATAR_PATH = os.path.join(os.path.dirname(__file__), "static", "claude.svg")
8+
from claude_code_sdk import query, ClaudeCodeOptions, AssistantMessage
9+
from claude_code_sdk.types import McpHttpServerConfig
10+
11+
12+
from jupyter_server.serverapp import ServerApp
3413

35-
PROMPT_TEMPLATE = """
36-
{{body}}
37-
38-
The user has selected the following files as attachements:
39-
40-
41-
"""
42-
43-
def input_dict_to_str(d: Dict[str, Any]) -> str:
44-
"""Convert input dictionary to string representation, omitting specified args."""
45-
args = []
46-
for k, v in d.items():
47-
if k not in OMIT_INPUT_ARGS:
48-
args.append(f"{k}={v}")
49-
return ', '.join(args)
50-
51-
52-
def tool_to_str(block: ToolUseBlock, persona_instance=None) -> str:
53-
"""Convert a ToolUseBlock to its string representation."""
54-
results = []
55-
56-
if block.name == 'TodoWrite':
57-
block_id = block.id if hasattr(block, 'id') else str(hash(str(block.input)))
58-
59-
if persona_instance and block_id in persona_instance._printed_todowrite_blocks:
60-
return ""
61-
62-
if persona_instance:
63-
persona_instance._printed_todowrite_blocks.add(block_id)
64-
65-
todos = block.input.get('todos', [])
66-
results.append('TodoWrite()')
67-
for todo in todos:
68-
content = todo.get('content')
69-
if content:
70-
results.append(f"* {content}")
71-
elif block.name in TOOL_PARAM_MAPPING:
72-
param_key = TOOL_PARAM_MAPPING[block.name]
73-
param_value = block.input.get(param_key, '')
74-
results.append(f"🛠️ {block.name}({param_value})")
75-
else:
76-
results.append(f"🛠️ {block.name}({input_dict_to_str(block.input)})")
77-
78-
return '\n'.join(results)
79-
80-
81-
def claude_message_to_str(message, persona_instance=None) -> Optional[str]:
82-
"""Convert a Claude Message to a string by extracting text content."""
83-
text_parts = []
84-
for block in message.content:
85-
if isinstance(block, TextBlock):
86-
text_parts.append(block.text)
87-
elif isinstance(block, ToolUseBlock):
88-
tool_str = tool_to_str(block, persona_instance)
89-
if tool_str:
90-
text_parts.append(tool_str)
91-
else:
92-
text_parts.append(str(block))
93-
return '\n'.join(text_parts) if text_parts else None
14+
from .templates import ClaudeCodeTemplateManager
15+
16+
17+
AVATAR_PATH = os.path.join(os.path.dirname(__file__), "static", "claude.svg")
9418

9519

9620
class ClaudeCodePersona(BasePersona):
9721
"""Claude Code persona for Jupyter AI integration."""
9822

9923
def __init__(self, *args, **kwargs):
10024
super().__init__(*args, **kwargs)
101-
self._printed_todowrite_blocks = set()
25+
self.template_mgr = ClaudeCodeTemplateManager(self)
10226

10327
@property
10428
def defaults(self) -> PersonaDefaults:
10529
"""Return default configuration for the Claude Code persona."""
10630
return PersonaDefaults(
10731
name="Claude",
10832
avatar_path=AVATAR_PATH,
109-
description="Claude Code",
33+
description="Claude Code persona",
11034
system_prompt="...",
11135
)
112-
36+
11337
async def _process_response_message(self, message_iterator) -> AsyncIterator[str]:
114-
"""Process response messages from Claude Code SDK."""
115-
async for response_message in message_iterator:
116-
self.log.info(str(response_message))
117-
if isinstance(response_message, AssistantMessage):
118-
msg_str = claude_message_to_str(response_message, self)
119-
if msg_str is not None:
120-
yield msg_str + '\n\n'
38+
"""Process response messages with template updates."""
39+
has_content = False
40+
template_was_used = False
41+
42+
async for message in message_iterator:
43+
self.log.info(str(message))
44+
if isinstance(message, AssistantMessage):
45+
result = await self.template_mgr.claude_message_to_str(message)
46+
# Template now handles everything - never stream individual components
47+
if self.template_mgr.active:
48+
template_was_used = True
49+
elif result is not None:
50+
# Only for messages without any tool usage (rare)
51+
has_content = True
52+
yield result + "\n\n"
53+
54+
# Complete template if active
55+
if self.template_mgr.active:
56+
await self.template_mgr.complete()
57+
template_was_used = True
58+
59+
# Always yield something to complete the stream
60+
if template_was_used:
61+
yield "" # Empty yield to signal completion when template handled everything
62+
elif not has_content:
63+
yield "" # Ensure stream completes for empty responses
12164

12265
def _generate_prompt(self, message: Message) -> str:
12366
attachment_ids = message.attachments
@@ -128,33 +71,153 @@ def _generate_prompt(self, message: Message) -> str:
12871
prompt = f"{message.body}\n\n"
12972
prompt += f"The user has attached the following files and may be referring to them in the above prompt:\n\n"
13073
for a in msg_attachments:
131-
if a['type'] == 'file':
74+
if a["type"] == "file":
13275
prompt += f"file_path={a['value']}"
133-
elif a['type'] == 'notebook':
134-
cells = list(c['id'] for c in a['cells'])
76+
elif a["type"] == "notebook":
77+
cells = list(c["id"] for c in a["cells"])
13578
# Claude Code's notebook tools only understand a single cell_id
13679
prompt += f"notebook_path={a['value']} cell_id={cells[0]}"
13780
self.log.info(prompt)
13881
return prompt
13982

83+
@functools.cache
84+
def _get_mcp_servers_config(
85+
self,
86+
) -> tuple[Dict[str, McpHttpServerConfig], List[str]]:
87+
"""
88+
Auto-detect and configure MCP servers from Jupyter Server extensions.
89+
90+
Checks if jupyter_server_mcp extension is available and adds it
91+
to the MCP server configuration along with allowed tools.
92+
93+
Returns:
94+
Tuple of (mcp_servers_config, allowed_tools_list)
95+
"""
96+
mcp_servers = {}
97+
allowed_tools = []
98+
# Check if jupyter_server_mcp extension is loaded
99+
try:
100+
server_app = ServerApp.instance()
101+
102+
# Look for the MCP extension in the server app's extension manager
103+
if hasattr(server_app, "extension_manager"):
104+
extensions = server_app.extension_manager.extensions
105+
106+
# Find jupyter_server_mcp extension
107+
mcp_extension = None
108+
for ext_name, ext_obj in extensions.items():
109+
if (
110+
ext_name == "jupyter_server_mcp"
111+
or ext_obj.__class__.__name__ == "MCPExtensionApp"
112+
):
113+
mcp_extension = ext_obj.extension_points[
114+
"jupyter_server_mcp"
115+
].app
116+
break
117+
118+
if mcp_extension and hasattr(mcp_extension, "mcp_server_instance"):
119+
# Extension is loaded and has an MCP server instance
120+
mcp_server = mcp_extension.mcp_server_instance
121+
if mcp_server:
122+
# Configure MCP server connection
123+
host = getattr(mcp_server, "host", "localhost")
124+
port = getattr(mcp_server, "port", 3001)
125+
name = getattr(mcp_server, "name", "Jupyter MCP Server")
126+
127+
server_config: McpHttpServerConfig = {
128+
"type": "http",
129+
"url": f"http://{host}:{port}/mcp",
130+
}
131+
132+
mcp_servers[name] = server_config
133+
134+
# Get available tools from the MCP server
135+
if (
136+
hasattr(mcp_server, "_registered_tools")
137+
and mcp_server._registered_tools
138+
):
139+
# Add all tools from this server to allowed_tools
140+
# Format: mcp__<serverName>__<toolName>
141+
server_name_clean = name.replace(" ", "_").replace("-", "_")
142+
for tool_name in mcp_server._registered_tools.keys():
143+
allowed_tool = f"mcp__{server_name_clean}__{tool_name}"
144+
allowed_tools.append(allowed_tool)
145+
146+
self.log.info(
147+
f"Auto-configured MCP server: {name} at {server_config['url']} with {len(mcp_server._registered_tools)} tools"
148+
)
149+
self.log.debug(f"Allowed tools: {allowed_tools}")
150+
else:
151+
# If no specific tools, allow all tools from the server
152+
server_name_clean = name.replace(" ", "_").replace("-", "_")
153+
allowed_tools.append(f"mcp__{server_name_clean}")
154+
self.log.info(
155+
f"Auto-configured MCP server: {name} at {server_config['url']} (allowing all tools)"
156+
)
157+
158+
except Exception as e:
159+
self.log.error(f"Could not auto-detect MCP server: {e}")
160+
161+
return mcp_servers, allowed_tools
162+
163+
def _get_system_prompt(self):
164+
"""Get the system prompt for Claude Code options."""
165+
return (
166+
"I am Claude Code, an AI assistant with access to development tools. "
167+
"When formatting responses, I use **bold text** for emphasis and section headers instead of markdown headings (# ## ###). "
168+
"I keep formatting clean and readable without large headers. "
169+
"For complex tasks requiring multiple steps (3+ actions), I proactively create a todo list using TodoWrite to track progress and keep the user informed of my plan."
170+
)
171+
140172
async def process_message(self, message: Message) -> None:
141173
"""Process incoming message and stream Claude Code response."""
142-
self._printed_todowrite_blocks.clear()
143-
async_gen = None
144-
prompt = self._generate_prompt(message)
174+
# Always set writing state at the start
175+
self.awareness.set_local_state_field("isWriting", True)
176+
177+
self.template_mgr.reset()
178+
145179
try:
146-
async_gen = query(
147-
prompt=prompt,
148-
options=ClaudeCodeOptions(
149-
max_turns=20,
150-
cwd=self.get_workspace_dir(),
151-
permission_mode='bypassPermissions'
152-
)
153-
)
180+
# Configure Claude Code - use workspace dir for better working directory detection
181+
chat_dir = self.get_chat_dir()
182+
workspace_dir = self.get_workspace_dir()
183+
184+
# Prefer workspace dir if available, fallback to chat dir
185+
working_dir = chat_dir if chat_dir else workspace_dir
186+
187+
self.log.info(f"Chat directory: {chat_dir}")
188+
self.log.info(f"Workspace directory: {workspace_dir}")
189+
self.log.info(f"Using working directory: {working_dir}")
190+
191+
# Auto-detect and configure MCP servers and allowed tools
192+
mcp_servers, mcp_allowed_tools = self._get_mcp_servers_config()
193+
194+
options = {
195+
"max_turns": 50,
196+
"cwd": working_dir,
197+
"permission_mode": "bypassPermissions",
198+
"system_prompt": self._get_system_prompt(),
199+
"mcp_servers": mcp_servers,
200+
"allowed_tools": mcp_allowed_tools,
201+
}
202+
203+
# Generate prompt from current message
204+
user_prompt = self._generate_prompt(message)
205+
206+
# Stream response directly with prompt
207+
async_gen = query(prompt=user_prompt, options=ClaudeCodeOptions(**options))
208+
209+
# Use stream_message to handle the streaming
154210
await self.stream_message(self._process_response_message(async_gen))
211+
155212
except Exception as e:
156-
self.log.error(f"Error in process_message: {e}")
157-
await self.send_message(f"Sorry, I have had an internal error while working on that: {e}")
213+
self.log.error(f"Error: {e}")
214+
if self.template_mgr.active:
215+
await self.template_mgr.complete()
216+
217+
try:
218+
await self.send_message(f"Sorry, error: {e}")
219+
except TypeError:
220+
self.send_message(f"Sorry, error: {e}")
158221
finally:
159-
if async_gen is not None:
160-
await async_gen.aclose()
222+
# Always clear writing state when done
223+
self.awareness.set_local_state_field("isWriting", False)

0 commit comments

Comments
 (0)