main
py 489 lines 16.7 KB
Raw
1 import os
2 import asyncio
3 from typing import Annotated, Literal, Union
4 from urllib.parse import urlparse
5 from openai import BaseModel
6 from pydantic import Field
7 import fastmcp
8 from fastmcp import FastMCP
9 import contextvars
10
11 from agent import AgentContext, AgentContextType, UserMessage
12 from helpers.persist_chat import remove_chat
13 from initialize import initialize_agent
14 from helpers.print_style import PrintStyle
15 from helpers import settings, projects
16 from starlette.middleware import Middleware
17 from starlette.middleware.base import BaseHTTPMiddleware
18 from starlette.exceptions import HTTPException as StarletteHTTPException
19 from starlette.types import ASGIApp, Receive, Scope, Send
20 from fastmcp.server.http import create_sse_app, create_base_app, build_resource_metadata_url # type: ignore
21 from starlette.routing import Mount # type: ignore
22 from starlette.requests import Request
23 import threading
24
25 _PRINTER = PrintStyle(italic=True, font_color="green", padding=False)
26
27 # Context variable to store project name from URL (per-request)
28 _mcp_project_name: contextvars.ContextVar[str | None] = contextvars.ContextVar('mcp_project_name', default=None)
29
30 mcp_server: FastMCP = FastMCP(
31 name="Agent Zero integrated MCP Server",
32 instructions="""
33 Connect to remote Agent Zero instance.
34 Agent Zero is a general AI assistant controlling it's linux environment.
35 Agent Zero can install software, manage files, execute commands, code, use internet, etc.
36 Agent Zero's environment is isolated unless configured otherwise.
37 """,
38 )
39
40
41 class ToolResponse(BaseModel):
42 status: Literal["success"] = Field(
43 description="The status of the response", default="success"
44 )
45 response: str = Field(
46 description="The response from the remote Agent Zero Instance"
47 )
48 chat_id: str = Field(description="The id of the chat this message belongs to.")
49
50
51 class ToolError(BaseModel):
52 status: Literal["error"] = Field(
53 description="The status of the response", default="error"
54 )
55 error: str = Field(
56 description="The error message from the remote Agent Zero Instance"
57 )
58 chat_id: str = Field(description="The id of the chat this message belongs to.")
59
60
61 SEND_MESSAGE_DESCRIPTION = """
62 Send a message to the remote Agent Zero Instance.
63 This tool is used to send a message to the remote Agent Zero Instance connected remotely via MCP.
64 """
65
66
67 @mcp_server.tool(
68 name="send_message",
69 description=SEND_MESSAGE_DESCRIPTION,
70 tags={
71 "agent_zero",
72 "chat",
73 "remote",
74 "communication",
75 "dialogue",
76 "sse",
77 "send",
78 "message",
79 "start",
80 "new",
81 "continue",
82 },
83 annotations={
84 "remote": True,
85 "readOnlyHint": False,
86 "destructiveHint": False,
87 "idempotentHint": False,
88 "openWorldHint": False,
89 "title": SEND_MESSAGE_DESCRIPTION,
90 },
91 )
92 async def send_message(
93 message: Annotated[
94 str,
95 Field(
96 description="The message to send to the remote Agent Zero Instance",
97 title="message",
98 ),
99 ],
100 attachments: (
101 Annotated[
102 list[str],
103 Field(
104 description="Optional: A list of attachments (file paths or web urls) to send to the remote Agent Zero Instance with the message. Default: Empty list",
105 title="attachments",
106 ),
107 ]
108 | None
109 ) = None,
110 chat_id: (
111 Annotated[
112 str,
113 Field(
114 description="Optional: ID of the chat. Used to continue a chat. This value is returned in response to sending previous message. Default: Empty string",
115 title="chat_id",
116 ),
117 ]
118 | None
119 ) = None,
120 persistent_chat: (
121 Annotated[
122 bool,
123 Field(
124 description="Optional: Whether to use a persistent chat. If true, the chat will be saved and can be continued later. Default: False.",
125 title="persistent_chat",
126 ),
127 ]
128 | None
129 ) = None,
130 ) -> Annotated[
131 Union[ToolResponse, ToolError],
132 Field(
133 description="The response from the remote Agent Zero Instance", title="response"
134 ),
135 ]:
136 # Get project name from context variable (set in proxy __call__)
137 project_name = _mcp_project_name.get()
138
139 context: AgentContext | None = None
140 if chat_id:
141 context = AgentContext.get(chat_id)
142 if not context:
143 return ToolError(error="Chat not found", chat_id=chat_id)
144 else:
145 # If the chat is found, we use the persistent chat flag to determine
146 # whether we should save the chat or delete it afterwards
147 # If we continue a conversation, it must be persistent
148 persistent_chat = True
149
150 # Validation: if project is in URL but context has different project
151 if project_name:
152 existing_project = context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
153 if existing_project and existing_project != project_name:
154 return ToolError(
155 error=f"Chat belongs to project '{existing_project}' but URL specifies '{project_name}'",
156 chat_id=chat_id
157 )
158 else:
159 config = initialize_agent()
160 context = AgentContext(config=config, type=AgentContextType.BACKGROUND)
161
162 # Activate project if specified in URL
163 if project_name:
164 try:
165 projects.activate_project(context.id, project_name)
166 except Exception as e:
167 return ToolError(error=f"Failed to activate project: {str(e)}", chat_id="")
168
169 if not message:
170 return ToolError(
171 error="Message is required", chat_id=context.id if persistent_chat else ""
172 )
173
174 try:
175 response = await _run_chat(context, message, attachments)
176 if not persistent_chat:
177 context.reset()
178 AgentContext.remove(context.id)
179 remove_chat(context.id)
180 return ToolResponse(
181 response=response, chat_id=context.id if persistent_chat else ""
182 )
183 except Exception as e:
184 return ToolError(error=str(e), chat_id=context.id if persistent_chat else "")
185
186
187 FINISH_CHAT_DESCRIPTION = """
188 Finish a chat with the remote Agent Zero Instance.
189 This tool is used to finish a persistent chat (send_message with persistent_chat=True) with the remote Agent Zero Instance connected remotely via MCP.
190 If you want to continue the chat, use the send_message tool instead.
191 Always use this tool to finish persistent chat conversations with remote Agent Zero.
192 """
193
194
195 @mcp_server.tool(
196 name="finish_chat",
197 description=FINISH_CHAT_DESCRIPTION,
198 tags={
199 "agent_zero",
200 "chat",
201 "remote",
202 "communication",
203 "dialogue",
204 "sse",
205 "finish",
206 "close",
207 "end",
208 "stop",
209 },
210 annotations={
211 "remote": True,
212 "readOnlyHint": False,
213 "destructiveHint": True,
214 "idempotentHint": False,
215 "openWorldHint": False,
216 "title": FINISH_CHAT_DESCRIPTION,
217 },
218 )
219 async def finish_chat(
220 chat_id: Annotated[
221 str,
222 Field(
223 description="ID of the chat to be finished. This value is returned in response to sending previous message.",
224 title="chat_id",
225 ),
226 ]
227 ) -> Annotated[
228 Union[ToolResponse, ToolError],
229 Field(
230 description="The response from the remote Agent Zero Instance", title="response"
231 ),
232 ]:
233 if not chat_id:
234 return ToolError(error="Chat ID is required", chat_id="")
235
236 context = AgentContext.get(chat_id)
237 if not context:
238 return ToolError(error="Chat not found", chat_id=chat_id)
239 else:
240 context.reset()
241 AgentContext.remove(context.id)
242 remove_chat(context.id)
243 return ToolResponse(response="Chat finished", chat_id=chat_id)
244
245
246 async def _run_chat(
247 context: AgentContext, message: str, attachments: list[str] | None = None
248 ):
249 try:
250 _PRINTER.print("MCP Chat message received")
251
252 # Attachment filenames for logging
253 attachment_filenames = []
254 if attachments:
255 for attachment in attachments:
256 if os.path.exists(attachment):
257 attachment_filenames.append(attachment)
258 else:
259 try:
260 url = urlparse(attachment)
261 if url.scheme in ["http", "https", "ftp", "ftps", "sftp"]:
262 attachment_filenames.append(attachment)
263 else:
264 _PRINTER.print(f"Skipping attachment: [{attachment}]")
265 except Exception:
266 _PRINTER.print(f"Skipping attachment: [{attachment}]")
267
268 _PRINTER.print("User message:")
269 _PRINTER.print(f"> {message}")
270 if attachment_filenames:
271 _PRINTER.print("Attachments:")
272 for filename in attachment_filenames:
273 _PRINTER.print(f"- {filename}")
274
275 task = context.communicate(
276 UserMessage(
277 message=message, system_message=[], attachments=attachment_filenames
278 )
279 )
280 result = await task.result()
281
282 # Success
283 _PRINTER.print(f"MCP Chat message completed: {result}")
284
285 return result
286
287 except Exception as e:
288 # Error
289 _PRINTER.print(f"MCP Chat message failed: {e}")
290
291 raise RuntimeError(f"MCP Chat message failed: {e}") from e
292
293
294 class DynamicMcpProxy:
295 _instance: "DynamicMcpProxy | None" = None
296
297 """A dynamic proxy that allows swapping the underlying MCP applications on the fly."""
298
299 def __init__(self):
300 cfg = settings.get_settings()
301 self.token = ""
302 self.sse_app: ASGIApp | None = None
303 self.http_app: ASGIApp | None = None
304 self.http_session_manager = None
305 self.http_session_task_group = None
306 self._lock = threading.RLock() # Use RLock to avoid deadlocks
307 self.reconfigure(cfg["mcp_server_token"])
308
309 @staticmethod
310 def get_instance():
311 if DynamicMcpProxy._instance is None:
312 DynamicMcpProxy._instance = DynamicMcpProxy()
313 return DynamicMcpProxy._instance
314
315 def reconfigure(self, token: str):
316 if self.token == token:
317 return
318
319 self.token = token
320 sse_path = f"/t-{self.token}/sse"
321 http_path = f"/t-{self.token}/http"
322 message_path = f"/t-{self.token}/messages/"
323
324 # Update settings in the MCP server instance if provided
325 # Keep FastMCP settings synchronized so downstream helpers that read these
326 # values (including deprecated accessors) resolve the runtime paths.
327 fastmcp.settings.message_path = message_path
328 fastmcp.settings.sse_path = sse_path
329 fastmcp.settings.streamable_http_path = http_path
330
331 # Create new MCP apps with updated settings
332 with self._lock:
333 middleware = [Middleware(BaseHTTPMiddleware, dispatch=mcp_middleware)]
334
335 self.sse_app = create_sse_app(
336 server=mcp_server,
337 message_path=message_path,
338 sse_path=sse_path,
339 auth=mcp_server.auth,
340 debug=fastmcp.settings.debug,
341 middleware=list(middleware),
342 )
343
344 self.http_app = self._create_custom_http_app(
345 http_path,
346 middleware=list(middleware),
347 )
348
349 def _create_custom_http_app(
350 self,
351 streamable_http_path: str,
352 *,
353 middleware: list[Middleware],
354 ) -> ASGIApp:
355 """Create a Streamable HTTP app with manual session manager lifecycle."""
356
357 from mcp.server.streamable_http_manager import StreamableHTTPSessionManager # type: ignore
358 from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware # type: ignore
359 import anyio
360
361 server_routes = []
362 server_middleware = []
363
364 self.http_session_task_group = None
365 self.http_session_manager = StreamableHTTPSessionManager(
366 app=mcp_server._mcp_server,
367 event_store=None,
368 json_response=True,
369 stateless=False,
370 )
371
372 async def handle_streamable_http(scope, receive, send):
373 if self.http_session_task_group is None:
374 self.http_session_task_group = anyio.create_task_group()
375 await self.http_session_task_group.__aenter__()
376 if self.http_session_manager:
377 self.http_session_manager._task_group = self.http_session_task_group
378
379 if self.http_session_manager:
380 await self.http_session_manager.handle_request(scope, receive, send)
381
382 auth_provider = mcp_server.auth
383
384 if auth_provider:
385 server_routes.extend(auth_provider.get_routes(mcp_path=streamable_http_path))
386 server_middleware.extend(auth_provider.get_middleware())
387
388 resource_url = auth_provider._get_resource_url(streamable_http_path)
389 resource_metadata_url = (
390 build_resource_metadata_url(resource_url) if resource_url else None
391 )
392
393 server_routes.append(
394 Mount(
395 streamable_http_path,
396 app=RequireAuthMiddleware(
397 handle_streamable_http,
398 auth_provider.required_scopes,
399 resource_metadata_url,
400 ),
401 )
402 )
403 else:
404 server_routes.append(
405 Mount(
406 streamable_http_path,
407 app=handle_streamable_http,
408 )
409 )
410
411 additional_routes = mcp_server._get_additional_http_routes()
412 if additional_routes:
413 server_routes.extend(additional_routes)
414
415 server_middleware.extend(middleware)
416
417 return create_base_app(
418 routes=server_routes,
419 middleware=server_middleware,
420 debug=fastmcp.settings.debug,
421 )
422
423 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
424 """Forward the ASGI calls to the appropriate app based on the URL path"""
425 with self._lock:
426 sse_app = self.sse_app
427 http_app = self.http_app
428
429 if not sse_app or not http_app:
430 raise RuntimeError("MCP apps not initialized")
431
432 # Route based on path
433 path = scope.get("path", "")
434
435 # Check for token in path (with or without project segment)
436 # Patterns: /t-{token}/sse, /t-{token}/p-{project}/sse, etc.
437 has_token = f"/t-{self.token}/" in path or f"t-{self.token}/" in path
438
439 # Extract project from path BEFORE cleaning and set in context variable
440 project_name = None
441 if "/p-" in path:
442 try:
443 parts = path.split("/p-")
444 if len(parts) > 1:
445 project_part = parts[1].split("/")[0]
446 if project_part:
447 project_name = project_part
448 _PRINTER.print(f"[MCP] Proxy extracted project from URL: {project_name}")
449 except Exception as e:
450 _PRINTER.print(f"[MCP] Failed to extract project in proxy: {e}")
451
452 # Store project in context variable (will be available in send_message)
453 _mcp_project_name.set(project_name)
454
455 # Strip project segment from path if present (e.g., /p-project_name/)
456 # This is needed because the underlying MCP apps were configured without project paths
457 cleaned_path = path
458 if "/p-" in path:
459 # Remove /p-{project}/ segment: /t-TOKEN/p-PROJECT/sse -> /t-TOKEN/sse
460 import re
461 cleaned_path = re.sub(r'/p-[^/]+/', '/', path)
462
463 # Update scope with cleaned path for the underlying app
464 modified_scope = dict(scope)
465 modified_scope['path'] = cleaned_path
466
467 if has_token and ("/sse" in path or "/messages" in path):
468 # Route to SSE app with cleaned path
469 await sse_app(modified_scope, receive, send)
470 elif has_token and "/http" in path:
471 # Route to HTTP app with cleaned path
472 await http_app(modified_scope, receive, send)
473 else:
474 raise StarletteHTTPException(
475 status_code=403, detail="MCP forbidden"
476 )
477
478
479 async def mcp_middleware(request: Request, call_next):
480 """Middleware to check if MCP server is enabled."""
481 # check if MCP server is enabled
482 cfg = settings.get_settings()
483 if not cfg["mcp_server_enabled"]:
484 PrintStyle.error("[MCP] Access denied: MCP server is disabled in settings.")
485 raise StarletteHTTPException(
486 status_code=403, detail="MCP server is disabled in settings."
487 )
488
489 return await call_next(request)