forked from odysseus-dev/odysseus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuiltin_mcp.py
More file actions
386 lines (339 loc) · 14.4 KB
/
Copy pathbuiltin_mcp.py
File metadata and controls
386 lines (339 loc) · 14.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
"""
builtin_mcp.py
Auto-registration of built-in MCP servers on startup.
Each server runs as a stdio subprocess managed by McpManager.
"""
import asyncio
import json
import logging
import os
import shutil
import subprocess
import sys
from core.platform_compat import IS_WINDOWS, which_tool
from src.runtime_paths import get_app_root
logger = logging.getLogger(__name__)
def _find_npx() -> str:
"""Find the npx binary, checking common locations if not on PATH.
On Windows the shim is `npx.cmd`, which `which_tool` resolves via PATHEXT.
"""
npx = which_tool("npx")
if npx:
return npx
if IS_WINDOWS:
# Minimal-PATH fallbacks: npm's global bin lives under %APPDATA%\npm,
# and node's installer dir carries npx.cmd alongside node.exe.
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
for candidate in (
os.path.join(appdata, "npm", "npx.cmd"),
r"C:\Program Files\nodejs\npx.cmd",
):
if os.path.isfile(candidate):
return candidate
node = which_tool("node")
if node:
cand = os.path.join(os.path.dirname(node), "npx.cmd")
if os.path.isfile(cand):
return cand
return "npx.cmd" # fallback, will fail with a clear error
# Common POSIX locations when PATH is minimal (e.g. systemd)
for candidate in [
os.path.expanduser("~/.npm-global/bin/npx"),
os.path.expanduser("~/.local/bin/npx"),
"/usr/local/bin/npx",
"/usr/bin/npx",
]:
if os.path.isfile(candidate):
return candidate
# Try to find node and use npx from same dir
node = shutil.which("node")
if node:
npx_candidate = os.path.join(os.path.dirname(node), "npx")
if os.path.isfile(npx_candidate):
return npx_candidate
return "npx" # fallback, will fail with a clear error
# Server definitions: id -> (script path relative to project root, display name)
#
# bash / python / filesystem / web_search were folded into native in-process
# execution (src/tool_execution.py:_direct_fallback). Those trivial subprocess
# wrappers are gone.
#
# image_gen / memory / rag / email still run as stdio MCP servers — each
# carries hundreds of LOC of unique IMAP / HTTP / manager logic not worth
# duplicating into the native path right now.
_BUILTIN_SERVERS = {
"image_gen": ("mcp_servers/image_gen_server.py", "Built-in: Image Generation"),
"memory": ("mcp_servers/memory_server.py", "Built-in: Memory"),
"rag": ("mcp_servers/rag_server.py", "Built-in: RAG"),
"email": ("mcp_servers/email_server.py", "Built-in: Email"),
}
# NPX-based built-in servers (run via npx, not Python)
_BUILTIN_NPX_SERVERS = {
"builtin_browser": {
"name": "Built-in: Browser",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--headless", "--caps", "vision"],
}
}
# Global flag to disable MCP if there are compatibility issues
MCP_DISABLED = os.environ.get("ODYSSEUS_DISABLE_MCP", "").lower() in ("1", "true", "yes")
BROWSER_MCP_REQUIRE_CACHE = os.environ.get("ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE", "").lower() in ("1", "true", "yes")
# Strong references to the fire-and-forget startup tasks scheduled below.
# asyncio only keeps weak references to tasks created via create_task, so
# without this the GC can collect a task mid-execution and the server
# registration silently never runs. Mirrors _spawn_bg in routes/chat_helpers.py.
_BG_TASKS: set[asyncio.Task] = set()
def _spawn_bg(coro) -> asyncio.Task:
"""Schedule a background task and hold a strong reference until it finishes."""
task = asyncio.create_task(coro)
_BG_TASKS.add(task)
task.add_done_callback(_BG_TASKS.discard)
return task
def _find_browser_executable() -> str:
"""Find a browser binary for the built-in Playwright MCP server.
Docker images ship Debian's `chromium`; desktop installs may already have
Chrome/Chromium in a conventional location. If nothing is found, return an
empty string and let Playwright MCP use its own default browser/channel.
"""
configured = os.environ.get("ODYSSEUS_BROWSER_EXECUTABLE", "").strip()
if configured:
return configured
for name in ("google-chrome", "chromium", "chromium-browser"):
path = shutil.which(name)
if path:
return path
for candidate in (
"/opt/google/chrome/chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
):
if os.path.isfile(candidate):
return candidate
return ""
def _browser_mcp_args(args: list[str]) -> list[str]:
"""Return Playwright MCP args with a concrete browser executable when found."""
out = list(args or [])
if "--executable-path" not in out:
browser = _find_browser_executable()
if browser:
out.extend(["--executable-path", browser])
if os.environ.get("ODYSSEUS_BROWSER_ISOLATED", "1").lower() not in ("0", "false", "no"):
if "--isolated" not in out and "--user-data-dir" not in out:
out.append("--isolated")
if os.environ.get("ODYSSEUS_BROWSER_NO_SANDBOX", "1").lower() not in ("0", "false", "no"):
if "--no-sandbox" not in out and "--sandbox" not in out:
out.append("--no-sandbox")
return out
def builtin_python_env(base_dir: str) -> dict[str, str]:
"""Environment for built-in Python MCP subprocesses.
The app root must be importable so mcp_servers can import local modules, but
replacing PYTHONPATH entirely hides site-packages in container/dev launches
that rely on PYTHONPATH for their active environment.
"""
existing = os.environ.get("PYTHONPATH", "")
parts = [base_dir]
for item in existing.split(os.pathsep):
if item and item not in parts:
parts.append(item)
return {"PYTHONPATH": os.pathsep.join(parts)}
async def register_builtin_servers(mcp_manager):
"""Connect all built-in MCP servers to the manager."""
if MCP_DISABLED:
logger.info("Built-in MCP servers disabled via ODYSSEUS_DISABLE_MCP")
return
base_dir = get_app_root()
python = sys.executable
async def _connect_python_server(server_id: str, script_path: str, name: str):
try:
ok = await mcp_manager.connect_server(
server_id=server_id,
name=name,
transport="stdio",
command=python,
args=[script_path],
env=builtin_python_env(base_dir),
)
if ok:
logger.info(f"Built-in MCP server registered: {name}")
else:
logger.warning(f"Built-in MCP server failed to connect: {name}")
except asyncio.CancelledError:
logger.warning(f"Built-in MCP server {name} cancelled")
raise
except BaseException as e:
logger.warning(f"Built-in MCP server {name} error: {type(e).__name__}: {e}")
for server_id, (script, name) in _BUILTIN_SERVERS.items():
script_path = os.path.join(base_dir, script)
if not os.path.exists(script_path):
logger.warning(f"Built-in MCP server script not found: {script_path}")
continue
_spawn_bg(_connect_python_server(server_id, script_path, name))
# Register NPX-based servers in the background (they take longer to start)
npx_path = _find_npx()
logger.info(f"NPX binary resolved to: {npx_path}")
async def _start_npx_servers():
await asyncio.sleep(3) # let Python servers finish first
for server_id, cfg in _BUILTIN_NPX_SERVERS.items():
# Browser automation is a shipped built-in, so the default path
# lets `npx -y` install @playwright/mcp on first start. Locked-down
# installs can opt back into the old no-network startup behavior
# with ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE=1.
args = _browser_mcp_args(cfg["args"]) if server_id == "builtin_browser" else list(cfg["args"])
pkg_spec = _npx_package_from_args(args)
if BROWSER_MCP_REQUIRE_CACHE and pkg_spec and not await _is_npx_package_cached(npx_path, pkg_spec):
logger.warning(
f"{cfg['name']} is not available.\n"
f" Reason: npm package {pkg_spec!r} is not installed in the npx cache.\n"
f" Impact: tools provided by this MCP server will be unavailable.\n"
f" Fix: {os.path.basename(npx_path)} -y {pkg_spec} --version\n"
f" (run once, then restart Odysseus)\n"
f" Notes: ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE=1 is set, "
f"so Odysseus will not install browser automation on startup."
)
continue
logger.info(f"Starting NPX server: {cfg['name']} ({npx_path} {' '.join(args)})")
try:
env = None
if server_id == "builtin_browser":
cache_home = os.environ.get(
"ODYSSEUS_BROWSER_MCP_CACHE",
os.path.join(base_dir, "data", "local", "playwright-mcp-cache"),
)
os.makedirs(cache_home, exist_ok=True)
env = {
"XDG_CACHE_HOME": cache_home,
"PLAYWRIGHT_BROWSERS_PATH": os.path.join(cache_home, "browsers"),
}
ok = await mcp_manager.connect_server(
server_id=server_id,
name=cfg["name"],
transport="stdio",
command=npx_path,
args=args,
env=env,
)
if ok:
logger.info(f"Built-in NPX server registered: {cfg['name']}")
else:
logger.warning(f"Built-in NPX server failed to connect: {cfg['name']}")
except asyncio.CancelledError:
raise
except BaseException as e:
logger.warning(f"Built-in NPX server {cfg['name']} error: {type(e).__name__}: {e}")
_spawn_bg(_start_npx_servers())
def _npx_package_from_args(args):
"""Pick the package spec out of an npx args list shaped like
['-y', '<package@version>', ...flags]. Returns None if the
convention doesn't match (we then skip the cache check and just
try the connect)."""
if not args:
return None
if "-y" in args:
idx = args.index("-y") + 1
if idx < len(args) and not args[idx].startswith("-"):
return args[idx]
# No -y prefix: first non-flag arg is the package
for a in args:
if not a.startswith("-"):
return a
return None
async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5):
"""Probe whether an npx package is already in the local cache.
First checks the local `_npx` cache for an installed package. If the
package is not found there, falls back to `npx --no-install <pkg>
--version` so older npm layouts still work without downloading.
"""
if _is_package_in_npx_cache(package_spec):
return True
try:
proc = await asyncio.create_subprocess_exec(
npx_path, "--no-install", package_spec, "--version",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except NotImplementedError:
try:
result = subprocess.run(
[npx_path, "--no-install", package_spec, "--version"],
capture_output=True,
timeout=timeout_s,
)
except (subprocess.TimeoutExpired, OSError, ValueError):
return False
return result.returncode == 0 and bool(result.stdout.strip())
except (OSError, ValueError):
return False
try:
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
try:
proc.kill()
await proc.wait()
except Exception:
pass
return False
except asyncio.CancelledError:
# The probe was cancelled (e.g. app shutdown). Reap the child so it
# isn't orphaned, then propagate the cancellation.
try:
proc.kill()
await proc.wait()
except Exception:
pass
raise
return proc.returncode == 0 and bool(stdout.strip())
def _is_package_in_npx_cache(package_spec):
"""Return True when npm's `_npx` cache already contains package_spec."""
package_name = _npx_package_name(package_spec)
if not package_name:
return False
for cache_root in _npm_cache_roots():
npx_root = os.path.join(cache_root, "_npx")
if _npx_cache_contains_package(npx_root, package_name):
return True
return False
def _npx_package_name(package_spec):
"""Strip a version/range suffix from an npm package spec."""
if not package_spec:
return ""
if package_spec.startswith("@"):
parts = package_spec.split("@", 2)
if len(parts) >= 3:
return f"@{parts[1]}"
return package_spec
return package_spec.split("@", 1)[0]
def _npm_cache_roots():
roots = []
configured = os.environ.get("npm_config_cache")
if configured:
roots.append(os.path.expanduser(configured))
roots.append(os.path.join(os.path.expanduser("~"), ".npm"))
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
roots.append(os.path.join(local_app_data, "npm-cache"))
return list(dict.fromkeys(roots))
def _npx_cache_contains_package(npx_root, package_name):
if not os.path.isdir(npx_root):
return False
package_path = os.path.join("node_modules", *package_name.split("/"), "package.json")
try:
entries = list(os.scandir(npx_root))
except OSError:
return False
for entry in entries:
try:
is_dir = entry.is_dir()
except OSError:
continue
cached_name = _cached_package_name(os.path.join(entry.path, package_path))
if is_dir and cached_name == package_name:
return True
return False
def _cached_package_name(package_json_path):
try:
with open(package_json_path, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, ValueError):
return ""
return str(data.get("name", "")).strip()