11import os
2- from typing import Dict , Any , List , Optional , AsyncIterator
2+ from typing import Dict , List , AsyncIterator
3+ import functools
34
45from jupyter_ai_persona_manager import BasePersona , PersonaDefaults
56from 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
9620class 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