MCP Server finalizing

frdel committed May 30, 2025 at 13:48 UTC aa63ebbb57cbf03a0c10448846575b07c1eb126f
7 files changed +265 -109
agent.py
+6 -11
@@ -13,7 +13,7 @@ from python.helpers.print_style import PrintStyle
13 from langchain_core.prompts import (
14 ChatPromptTemplate,
15 )
16 -from langchain_core.messages import HumanMessage, SystemMessage, AIMessage, BaseMessage
16 +from langchain_core.messages import HumanMessage, SystemMessage, BaseMessage
17
18 import python.helpers.log as Log
19 from python.helpers.dirty_json import DirtyJson
@@ -121,21 +121,16 @@ class AgentContext:
121 def nudge(self):
122 self.kill_process()
123 self.paused = False
124 - if self.streaming_agent:
125 - current_agent = self.streaming_agent
126 - else:
127 - current_agent = self.agent0
128 -
129 - self.task = self.run_task(current_agent.monologue)
124 + self.task = self.run_task(self.get_agent().monologue)
125 return self.task
126
127 + def get_agent(self):
128 + return self.streaming_agent or self.agent0
129 +
130 def communicate(self, msg: "UserMessage", broadcast_level: int = 1):
131 self.paused = False # unpause if paused
132
135 - if self.streaming_agent:
136 - current_agent = self.streaming_agent
137 - else:
138 - current_agent = self.agent0
133 + current_agent = self.get_agent()
134
135 if self.task and self.task.is_alive():
136 # set intervention messages to agent(s):
python/helpers/mcp_server.py
+143 -94
@@ -20,21 +20,31 @@ _PRINTER = PrintStyle(italic=True, font_color="green", padding=False)
20 mcp_server: FastMCP = FastMCP(
21 name="Agent Zero integrated MCP Server",
22 instructions="""
23 - This server connects you to the Agent Zero instance running on the remote server.
24 - It exposes tools to interact with the remote Agent Zero instance.
23 + Connect to remote Agent Zero instance.
24 + Agent Zero is a general AI assistant controlling it's linux environment.
25 + Agent Zero can install software, manage files, execute commands, code, use internet, etc.
26 + Agent Zero's environment is isolated unless configured otherwise.
27 """,
28 )
29
30
31 class ToolResponse(BaseModel):
30 - status: Literal["success"] = Field(description="The status of the response", default="success")
31 - response: str = Field(description="The response from the remote Agent Zero Instance")
32 + status: Literal["success"] = Field(
33 + description="The status of the response", default="success"
34 + )
35 + response: str = Field(
36 + description="The response from the remote Agent Zero Instance"
37 + )
38 chat_id: str = Field(description="The id of the chat this message belongs to.")
39
40
41 class ToolError(BaseModel):
36 - status: Literal["error"] = Field(description="The status of the response", default="error")
37 - error: str = Field(description="The error message from the remote Agent Zero Instance")
42 + status: Literal["error"] = Field(
43 + description="The status of the response", default="error"
44 + )
45 + error: str = Field(
46 + description="The error message from the remote Agent Zero Instance"
47 + )
48 chat_id: str = Field(description="The id of the chat this message belongs to.")
49
50
@@ -47,7 +57,19 @@ This tool is used to send a message to the remote Agent Zero Instance connected
57 @mcp_server.tool(
58 name="send_message",
59 description=SEND_MESSAGE_DESCRIPTION,
50 - tags={"agent_zero", "chat", "remote", "communication", "dialogue", "sse", "send", "message", "start", "new", "continue"},
60 + tags={
61 + "agent_zero",
62 + "chat",
63 + "remote",
64 + "communication",
65 + "dialogue",
66 + "sse",
67 + "send",
68 + "message",
69 + "start",
70 + "new",
71 + "continue",
72 + },
73 annotations={
74 "remote": True,
75 "readOnlyHint": False,
@@ -58,20 +80,49 @@ This tool is used to send a message to the remote Agent Zero Instance connected
80 },
81 )
82 async def send_message(
61 - message: Annotated[str, Field(description="The message to send to the remote Agent Zero Instance", title="message")],
62 - attachments: Annotated[list[str], Field(
63 - 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",
64 - title="attachments",
65 - )] | None = None,
66 - chat_id: Annotated[str, Field(
67 - description="Optional: ID of the chat. Used to continue a chat. This value is returned in response to sending previous message. Default: Empty string",
68 - title="chat_id",
69 - )] | None = None,
70 - persistent_chat: Annotated[bool, Field(
71 - description="Optional: Whether to use a persistent chat. If true, the chat will be saved and can be continued later. Default: False.",
72 - title="persistent_chat",
73 - )] | None = None,
74 -) -> Annotated[Union[ToolResponse, ToolError], Field(description="The response from the remote Agent Zero Instance", title="response")]:
83 + message: Annotated[
84 + str,
85 + Field(
86 + description="The message to send to the remote Agent Zero Instance",
87 + title="message",
88 + ),
89 + ],
90 + attachments: (
91 + Annotated[
92 + list[str],
93 + Field(
94 + 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",
95 + title="attachments",
96 + ),
97 + ]
98 + | None
99 + ) = None,
100 + chat_id: (
101 + Annotated[
102 + str,
103 + Field(
104 + description="Optional: ID of the chat. Used to continue a chat. This value is returned in response to sending previous message. Default: Empty string",
105 + title="chat_id",
106 + ),
107 + ]
108 + | None
109 + ) = None,
110 + persistent_chat: (
111 + Annotated[
112 + bool,
113 + Field(
114 + description="Optional: Whether to use a persistent chat. If true, the chat will be saved and can be continued later. Default: False.",
115 + title="persistent_chat",
116 + ),
117 + ]
118 + | None
119 + ) = None,
120 +) -> Annotated[
121 + Union[ToolResponse, ToolError],
122 + Field(
123 + description="The response from the remote Agent Zero Instance", title="response"
124 + ),
125 +]:
126 context: AgentContext | None = None
127 if chat_id:
128 context = AgentContext.get(chat_id)
@@ -87,7 +138,9 @@ async def send_message(
138 context = AgentContext(config=config, type=AgentContextType.MCP)
139
140 if not message:
90 - return ToolError(error="Message is required", chat_id=context.id if persistent_chat else "")
141 + return ToolError(
142 + error="Message is required", chat_id=context.id if persistent_chat else ""
143 + )
144
145 try:
146 response = await _run_chat(context, message, attachments)
@@ -95,7 +148,9 @@ async def send_message(
148 context.reset()
149 AgentContext.remove(context.id)
150 remove_chat(context.id)
98 - return ToolResponse(response=response, chat_id=context.id if persistent_chat else "")
151 + return ToolResponse(
152 + response=response, chat_id=context.id if persistent_chat else ""
153 + )
154 except Exception as e:
155 return ToolError(error=str(e), chat_id=context.id if persistent_chat else "")
156
@@ -111,7 +166,18 @@ Always use this tool to finish persistent chat conversations with remote Agent Z
166 @mcp_server.tool(
167 name="finish_chat",
168 description=FINISH_CHAT_DESCRIPTION,
114 - tags={"agent_zero", "chat", "remote", "communication", "dialogue", "sse", "finish", "close", "end", "stop"},
169 + tags={
170 + "agent_zero",
171 + "chat",
172 + "remote",
173 + "communication",
174 + "dialogue",
175 + "sse",
176 + "finish",
177 + "close",
178 + "end",
179 + "stop",
180 + },
181 annotations={
182 "remote": True,
183 "readOnlyHint": False,
@@ -122,11 +188,19 @@ Always use this tool to finish persistent chat conversations with remote Agent Z
188 },
189 )
190 async def finish_chat(
125 - chat_id: Annotated[str, Field(
126 - description="ID of the chat to be finished. This value is returned in response to sending previous message.",
127 - title="chat_id",
128 - )]
129 -) -> Annotated[Union[ToolResponse, ToolError], Field(description="The response from the remote Agent Zero Instance", title="response")]:
191 + chat_id: Annotated[
192 + str,
193 + Field(
194 + description="ID of the chat to be finished. This value is returned in response to sending previous message.",
195 + title="chat_id",
196 + ),
197 + ]
198 +) -> Annotated[
199 + Union[ToolResponse, ToolError],
200 + Field(
201 + description="The response from the remote Agent Zero Instance", title="response"
202 + ),
203 +]:
204 if not chat_id:
205 return ToolError(error="Chat ID is required", chat_id="")
206
@@ -140,74 +214,49 @@ async def finish_chat(
214 return ToolResponse(response="Chat finished", chat_id=chat_id)
215
216
143 -async def _run_chat(context: AgentContext, message: str, attachments: list[str] | None = None):
144 - async def _run_chat_wrapper(context: AgentContext, message: str, attachments: list[str] | None = None):
145 - # the agent instance - init in try block
146 - agent = None
147 -
148 - try:
149 - _PRINTER.print("MCP Chat message received")
150 -
151 - agent = context.streaming_agent or context.agent0
152 -
153 - # Pcurrent_taskhment filenames for logging
154 - attachment_filenames = []
155 - if attachments:
156 - for attachment in attachments:
157 - if os.path.exists(attachment):
158 - attachment_filenames.append(attachment)
159 - else:
160 - try:
161 - url = urlparse(attachment)
162 - if url.scheme in ["http", "https", "ftp", "ftps", "sftp"]:
163 - attachment_filenames.append(attachment)
164 - else:
165 - _PRINTER.print(f"Skipping attachment: [{attachment}]")
166 - except Exception:
217 +async def _run_chat(
218 + context: AgentContext, message: str, attachments: list[str] | None = None
219 +):
220 + try:
221 + _PRINTER.print("MCP Chat message received")
222 +
223 + # Pcurrent_taskhment filenames for logging
224 + attachment_filenames = []
225 + if attachments:
226 + for attachment in attachments:
227 + if os.path.exists(attachment):
228 + attachment_filenames.append(attachment)
229 + else:
230 + try:
231 + url = urlparse(attachment)
232 + if url.scheme in ["http", "https", "ftp", "ftps", "sftp"]:
233 + attachment_filenames.append(attachment)
234 + else:
235 _PRINTER.print(f"Skipping attachment: [{attachment}]")
168 -
169 - _PRINTER.print("User message:")
170 - _PRINTER.print(f"> {message}")
171 - if attachment_filenames:
172 - _PRINTER.print("Attachments:")
173 - for filename in attachment_filenames:
174 - _PRINTER.print(f"- {filename}")
175 -
176 - # Log the message with message_id and attachments
177 - context.log.log(
178 - type="user",
179 - heading="User message",
180 - content=message,
181 - kvps={"attachments": attachment_filenames},
182 - id=str(uuid.uuid4()),
236 + except Exception:
237 + _PRINTER.print(f"Skipping attachment: [{attachment}]")
238 +
239 + _PRINTER.print("User message:")
240 + _PRINTER.print(f"> {message}")
241 + if attachment_filenames:
242 + _PRINTER.print("Attachments:")
243 + for filename in attachment_filenames:
244 + _PRINTER.print(f"- {filename}")
245 +
246 + task = context.communicate(
247 + UserMessage(
248 + message=message, system_message=[], attachments=attachment_filenames
249 )
250 + )
251 + result = await task.result()
252
185 - agent.hist_add_user_message(
186 - UserMessage(
187 - message=message,
188 - system_message=[],
189 - attachments=attachment_filenames))
190 -
191 - # Persist after setting up the context but before running the agent
192 - save_tmp_chat(context)
253 + # Success
254 + _PRINTER.print(f"MCP Chat message completed: {result}")
255
194 - result = await agent.monologue()
256 + return result
257
196 - # Success
197 - _PRINTER.print(f"MCP Chat message completed: {result}")
198 - save_tmp_chat(context)
199 -
200 - return result
201 -
202 - except Exception as e:
203 - # Error
204 - _PRINTER.print(f"MCP Chat message failed: {e}")
205 - if agent:
206 - agent.handle_critical_exception(e)
207 -
208 - raise RuntimeError(f"MCP Chat message failed: {e}") from e
258 + except Exception as e:
259 + # Error
260 + _PRINTER.print(f"MCP Chat message failed: {e}")
261
210 - deferred_task = DeferredTask(thread_name="mcp_chat_" + context.id)
211 - deferred_task.start_task(_run_chat_wrapper, context, message, attachments)
212 - asyncio.create_task(asyncio.sleep(0.1)) # Ensure background execution doesn't exit immediately on async await
213 - return await deferred_task.result()
262 + raise RuntimeError(f"MCP Chat message failed: {e}") from e
python/helpers/settings.py
+27
@@ -62,6 +62,9 @@ class Settings(TypedDict):
62 stt_silence_duration: int
63 stt_waiting_timeout: int
64
65 + mcp_server_enabled: bool
66 +
67 +
68
69 class PartialSettings(Settings, total=False):
70 pass
@@ -677,6 +680,28 @@ def convert_out(settings: Settings) -> SettingsOutput:
680 "tab": "agent",
681 }
682
683 + # MCP section
684 + mcp_server_fields: list[SettingsField] = []
685 +
686 + mcp_server_fields.append(
687 + {
688 + "id": "mcp_server_enabled",
689 + "title": "Enable A0 MCP Server",
690 + "description": "Expose Agent Zero as an SSE MCP server. This will make this A0 instance available to MCP clients.",
691 + "type": "switch",
692 + "value": settings["mcp_server_enabled"],
693 + }
694 + )
695 +
696 + mcp_server_section: SettingsSection = {
697 + "id": "mcp_server",
698 + "title": "A0 MCP Server",
699 + "description": "Agent Zero can be exposed as an SSE MCP server. It can then be accessed by MCP clients on the URL and port of the web UI + /mcp/sse, for example http://localhost:5000/mcp/sse. The same applies to public URL using Cloudflare Tunnel.",
700 + "fields": mcp_server_fields,
701 + "tab": "mcp",
702 + }
703 +
704 +
705 # Add the section to the result
706 result: SettingsOutput = {
707 "sections": [
@@ -689,6 +714,7 @@ def convert_out(settings: Settings) -> SettingsOutput:
714 stt_section,
715 api_keys_section,
716 auth_section,
717 + mcp_server_section,
718 dev_section,
719 ]
720 }
@@ -839,6 +865,7 @@ def get_default_settings() -> Settings:
865 stt_silence_threshold=0.3,
866 stt_silence_duration=1000,
867 stt_waiting_timeout=2000,
868 + mcp_server_enabled=False,
869 )
870
871
run_ui.py
+23 -4
@@ -9,7 +9,7 @@ import threading
9 import signal
10 from flask import Flask, request, Response
11 from flask_basicauth import BasicAuth
12 -from python.helpers import errors, files, git
12 +from python.helpers import errors, files, git, settings
13 from python.helpers.files import get_abs_path
14 from python.helpers import persist_chat, runtime, dotenv, process
15 from python.helpers.cloudflare_tunnel import CloudflareTunnel
@@ -19,6 +19,9 @@ from python.helpers.job_loop import run_loop
19 from python.helpers.print_style import PrintStyle
20 from python.helpers.task_scheduler import TaskScheduler
21 from python.helpers.defer import DeferredTask
22 +from starlette.middleware import Middleware
23 +from starlette.middleware.base import BaseHTTPMiddleware
24 +from starlette.exceptions import HTTPException as StarletteHTTPException
25
26
27 # Set the new timezone to 'UTC'
@@ -234,6 +237,22 @@ def run():
237 for handler in handlers:
238 register_api_handler(webapp, handler)
239
240 +
241 + # define a Starlette-compatible middleware handler
242 +
243 +
244 + async def mcp_middleware(request, call_next):
245 + set = settings.get_settings()
246 + if not set["mcp_server_enabled"]:
247 + # raise a proper Starlette HTTPException with a clear message
248 + PrintStyle.error("[MCP] Access denied: MCP server is disabled in settings.")
249 + raise StarletteHTTPException(status_code=403, detail="MCP server is disabled in settings.")
250 + return await call_next(request)
251 +
252 + mcp_middlewares = [
253 + Middleware(BaseHTTPMiddleware, dispatch=mcp_middleware)
254 + ]
255 +
256 mcp_app = create_sse_app(
257 server=mcp_server_instance,
258 message_path=mcp_server_instance.settings.message_path,
@@ -242,13 +261,13 @@ def run():
261 auth_settings=mcp_server_instance.settings.auth,
262 debug=mcp_server_instance.settings.debug,
263 routes=mcp_server_instance._additional_http_routes,
245 - middleware=None
264 + middleware=mcp_middlewares
265 )
266
267 # add the webapp and mcp to the app
268 app = DispatcherMiddleware(webapp, {
250 - "/mcp": ASGIMiddleware(app=mcp_app),
251 - })
269 + "/mcp": ASGIMiddleware(app=mcp_app), # type: ignore
270 + }) # type: ignore
271 PrintStyle().debug("Registered middleware for MCP")
272
273 try:
webui/index.html
+4
@@ -583,6 +583,10 @@
583 :class="{'active': activeTab === 'external'}"
584 @click="switchTab('external')"
585 title="External Services">External Services</div>
586 + <div class="settings-tab"
587 + :class="{'active': activeTab === 'mcp'}"
588 + @click="switchTab('mcp')"
589 + title="MCP">MCP</div>
590 <div class="settings-tab"
591 :class="{'active': activeTab === 'developer'}"
592 @click="switchTab('developer')"
webui/public/mcp_client.svg new
+35
@@ -0,0 +1,35 @@
1 +<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2 +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3 +<svg width="100%" height="100%" viewBox="0 0 23 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-miterlimit:10;">
4 + <g transform="matrix(-1,0,0,1,22.6247,0)">
5 + <g>
6 + <g transform="matrix(0.940988,-0.940988,1.34929,1.34929,-15.8352,15.6937)">
7 + <path d="M13.071,10.55L12.45,10.116C11.934,9.757 11.934,9.172 12.45,8.812L17.6,5.22C18.116,4.86 18.954,4.86 19.47,5.22L20.2,5.729L20.2,5.042C20.2,4.533 19.607,4.12 18.877,4.12L12.303,4.12C11.573,4.12 10.98,4.533 10.98,5.042L10.98,9.628C10.98,10.137 11.573,10.55 12.303,10.55L13.071,10.55Z" style="fill:none;stroke:black;stroke-width:0.3px;"/>
8 + </g>
9 + <g transform="matrix(1.52599,-1.29875e-16,1.12508e-16,2.05479,-8.98544,-4.04941)">
10 + <path d="M12.9,10.55L19.047,10.55C19.683,10.55 20.2,10.166 20.2,9.693L20.2,4.977C20.2,4.504 19.683,4.12 19.047,4.12L13.033,4.12" style="fill:none;stroke:black;stroke-width:0.28px;"/>
11 + </g>
12 + <g transform="matrix(1.52599,-1.29875e-16,1.12508e-16,2.05479,-8.98544,-4.04941)">
13 + <path d="M13.033,4.12L12.133,4.12C11.497,4.12 10.98,4.504 10.98,4.977L10.98,9.693C10.98,10.166 11.497,10.55 12.133,10.55L12.9,10.55" style="fill:none;stroke:black;stroke-width:0.28px;"/>
14 + </g>
15 + <g transform="matrix(1,0,0,1,-1.40112,-0.184252)">
16 + <circle cx="5.735" cy="11.144" r="1.289"/>
17 + </g>
18 + <g transform="matrix(0.44605,0,0,1,2.96694,0)">
19 + <path d="M3.717,10.959L9.898,10.959" style="fill:none;stroke:black;stroke-width:0.65px;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:1.5;"/>
20 + </g>
21 + <g transform="matrix(0.707107,0.707107,-0.707107,0.707107,9.57979,-6.31316)">
22 + <rect x="9.991" y="6.37" width="3.843" height="3.843"/>
23 + </g>
24 + <g transform="matrix(1,0,0,1,-1.72131,-1.70023)">
25 + <path d="M20.015,13.06L20.942,14.516L22.613,14.948L21.514,16.279L21.621,18.001L20.015,17.368L18.41,18.001L18.516,16.279L17.417,14.948L19.089,14.516L20.015,13.06Z"/>
26 + </g>
27 + <g transform="matrix(1,0,0,1,-0.801743,-0.545185)">
28 + <circle cx="19.021" cy="8.675" r="2.364"/>
29 + </g>
30 + <g transform="matrix(1,0,0,1,0.0535086,0.357019)">
31 + <path d="M14.652,12.568C14.652,12.099 14.271,11.719 13.802,11.719L10.647,11.719C10.178,11.719 9.797,12.099 9.797,12.568L9.797,14.267C9.797,14.736 10.178,15.117 10.647,15.117L11.375,15.117L10.768,16.573L12.103,15.117L13.802,15.117C14.271,15.117 14.652,14.736 14.652,14.267L14.652,12.568Z"/>
32 + </g>
33 + </g>
34 + </g>
35 +</svg>
webui/public/mcp_server.svg new
+27
@@ -0,0 +1,27 @@
1 +<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2 +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3 +<svg width="100%" height="100%" viewBox="0 0 23 22" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
4 + <g>
5 + <g transform="matrix(0.0161395,0,0,0.0161395,7.32574,2.96434)">
6 + <g>
7 + <path d="M717.77,788.27C638.99,652.89 559.87,516.92 479.15,378.22C399.29,516.59 320.58,652.95 241.99,789.12L120,789.12C239.91,581.87 479.49,170.89 479.49,170.89C479.49,170.89 720.12,580.92 840,788.27L717.77,788.27Z" style="fill-rule:nonzero;stroke:rgb(122,122,122);stroke-width:1px;"/>
8 + <path d="M633.08,788.85L323.54,788.85C344.15,753.01 364.09,718.33 383.88,683.93L574.1,683.93C593.38,718.23 612.57,752.36 633.08,788.85Z" style="fill-rule:nonzero;stroke:rgb(122,122,122);stroke-width:1px;"/>
9 + </g>
10 + </g>
11 + <g transform="matrix(0.940988,-0.940988,1.34929,1.34929,-15.8352,15.6937)">
12 + <path d="M13.071,10.55L12.45,10.116C11.934,9.757 11.934,9.172 12.45,8.812L17.6,5.22C18.116,4.86 18.954,4.86 19.47,5.22L20.2,5.729L20.2,5.042C20.2,4.533 19.607,4.12 18.877,4.12L12.303,4.12C11.573,4.12 10.98,4.533 10.98,5.042L10.98,9.628C10.98,10.137 11.573,10.55 12.303,10.55L13.071,10.55Z" style="fill:none;stroke:black;stroke-width:0.3px;stroke-linejoin:miter;stroke-miterlimit:10;"/>
13 + </g>
14 + <g transform="matrix(1.52599,-1.29875e-16,1.12508e-16,2.05479,-8.98544,-4.04941)">
15 + <path d="M12.9,10.55L19.047,10.55C19.683,10.55 20.2,10.166 20.2,9.693L20.2,4.977C20.2,4.504 19.683,4.12 19.047,4.12L13.033,4.12" style="fill:none;stroke:black;stroke-width:0.28px;stroke-linejoin:miter;stroke-miterlimit:10;"/>
16 + </g>
17 + <g transform="matrix(1.52599,-1.29875e-16,1.12508e-16,2.05479,-8.98544,-4.04941)">
18 + <path d="M13.033,4.12L12.133,4.12C11.497,4.12 10.98,4.504 10.98,4.977L10.98,9.693C10.98,10.166 11.497,10.55 12.133,10.55L12.9,10.55" style="fill:none;stroke:black;stroke-width:0.28px;stroke-linejoin:miter;stroke-miterlimit:10;"/>
19 + </g>
20 + <g transform="matrix(1,0,0,1,-1.40112,-0.184252)">
21 + <circle cx="5.735" cy="11.144" r="1.289"/>
22 + </g>
23 + <g transform="matrix(0.501653,0,0,1,2.76026,0)">
24 + <path d="M3.717,10.959L9.898,10.959" style="fill:none;stroke:black;stroke-width:0.65px;stroke-miterlimit:1.5;"/>
25 + </g>
26 + </g>
27 +</svg>