207 lines
6.3 KiB
Python
Executable File
207 lines
6.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""odysseus-mcp — shell wrapper for MCP (Model Context Protocol) servers.
|
|
|
|
MCP servers are configured in the `mcp_servers` table. The web app's
|
|
McpManager handles live connections; this CLI manages the *config*
|
|
(read, add, enable/disable, delete). Runtime connection state lives
|
|
in the manager process — query it via the web UI or `curl /api/mcp/servers`.
|
|
|
|
odysseus-mcp list # configured servers
|
|
odysseus-mcp show SERVER_ID
|
|
odysseus-mcp enable SERVER_ID
|
|
odysseus-mcp disable SERVER_ID
|
|
odysseus-mcp add --name "Filesystem" --transport stdio \\
|
|
--command npx --args '["@modelcontextprotocol/server-filesystem","/tmp"]'
|
|
odysseus-mcp delete SERVER_ID
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import sys
|
|
import os, sys
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
|
|
from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT
|
|
quiet_logs()
|
|
|
|
import argparse, json, logging, os, sys, uuid
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from core.database import SessionLocal, McpServer
|
|
quiet_logs()
|
|
except ModuleNotFoundError as e:
|
|
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
|
|
sys.exit(2)
|
|
|
|
|
|
def _json_list(raw) -> list:
|
|
try:
|
|
value = json.loads(raw) if raw else []
|
|
except (TypeError, json.JSONDecodeError):
|
|
return []
|
|
return value if isinstance(value, list) else []
|
|
|
|
|
|
def _json_dict(raw) -> dict:
|
|
try:
|
|
value = json.loads(raw) if raw else {}
|
|
except (TypeError, json.JSONDecodeError):
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _serialize(s: "McpServer", redact_env: bool = True) -> dict:
|
|
args_arr = _json_list(s.args)
|
|
env_obj = _json_dict(s.env)
|
|
if redact_env and isinstance(env_obj, dict):
|
|
env_obj = {k: ("***" if v else "") for k, v in env_obj.items()}
|
|
return {
|
|
"id": s.id,
|
|
"name": s.name,
|
|
"transport": s.transport,
|
|
"command": s.command or "",
|
|
"args": args_arr,
|
|
"env": env_obj,
|
|
"url": s.url or "",
|
|
"is_enabled": bool(s.is_enabled),
|
|
"has_oauth": bool(s.oauth_config),
|
|
"created_at": s.created_at.isoformat() if s.created_at else "",
|
|
}
|
|
|
|
|
|
def cmd_list(args):
|
|
db = SessionLocal()
|
|
try:
|
|
rows = db.query(McpServer).order_by(McpServer.name.asc()).all()
|
|
emit([_serialize(s) for s in rows], args)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def cmd_show(args):
|
|
db = SessionLocal()
|
|
try:
|
|
s = db.get(McpServer, args.id)
|
|
if not s:
|
|
fail(f"no MCP server with id {args.id!r}")
|
|
# show shows env keys; values still redacted unless --reveal
|
|
out = _serialize(s, redact_env=not args.reveal)
|
|
emit(out, args)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def cmd_enable(args):
|
|
_set_enabled(args.id, True, args)
|
|
|
|
|
|
def cmd_disable(args):
|
|
_set_enabled(args.id, False, args)
|
|
|
|
|
|
def _set_enabled(server_id: str, enabled: bool, args):
|
|
db = SessionLocal()
|
|
try:
|
|
s = db.get(McpServer, server_id)
|
|
if not s:
|
|
fail(f"no MCP server with id {server_id!r}")
|
|
s.is_enabled = enabled
|
|
db.commit()
|
|
emit({"ok": True, "id": s.id, "is_enabled": s.is_enabled}, args)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def cmd_add(args):
|
|
if args.transport == "stdio" and not args.command:
|
|
fail("--command is required for stdio transport")
|
|
if args.transport == "sse" and not args.url:
|
|
fail("--url is required for sse transport")
|
|
try:
|
|
args_arr = json.loads(args.args) if args.args else []
|
|
if not isinstance(args_arr, list):
|
|
raise ValueError("--args must be a JSON array")
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
fail(f"invalid --args: {e}")
|
|
try:
|
|
env_obj = json.loads(args.env) if args.env else {}
|
|
if not isinstance(env_obj, dict):
|
|
raise ValueError("--env must be a JSON object")
|
|
except (json.JSONDecodeError, ValueError) as e:
|
|
fail(f"invalid --env: {e}")
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
s = McpServer(
|
|
id=str(uuid.uuid4()),
|
|
name=args.name,
|
|
transport=args.transport,
|
|
command=args.command or None,
|
|
args=json.dumps(args_arr) if args_arr else None,
|
|
env=json.dumps(env_obj) if env_obj else None,
|
|
url=args.url or None,
|
|
is_enabled=not args.disabled,
|
|
)
|
|
db.add(s)
|
|
db.commit()
|
|
db.refresh(s)
|
|
emit(_serialize(s), args)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def cmd_delete(args):
|
|
db = SessionLocal()
|
|
try:
|
|
s = db.get(McpServer, args.id)
|
|
if not s:
|
|
fail(f"no MCP server with id {args.id!r}")
|
|
snap = _serialize(s)
|
|
db.delete(s)
|
|
db.commit()
|
|
emit({"ok": True, "deleted": snap}, args)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _build_parser():
|
|
common = argparse.ArgumentParser(add_help=False)
|
|
common.add_argument("--pretty", action="store_true")
|
|
p = argparse.ArgumentParser(prog="odysseus-mcp", parents=[common])
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
pl = sub.add_parser("list", parents=[common])
|
|
pl.set_defaults(func=cmd_list)
|
|
|
|
psh = sub.add_parser("show", parents=[common])
|
|
psh.add_argument("id")
|
|
psh.add_argument("--reveal", action="store_true", help="show env values unredacted")
|
|
psh.set_defaults(func=cmd_show)
|
|
|
|
pe = sub.add_parser("enable", parents=[common])
|
|
pe.add_argument("id")
|
|
pe.set_defaults(func=cmd_enable)
|
|
|
|
pdis = sub.add_parser("disable", parents=[common])
|
|
pdis.add_argument("id")
|
|
pdis.set_defaults(func=cmd_disable)
|
|
|
|
pa = sub.add_parser("add", parents=[common])
|
|
pa.add_argument("--name", required=True)
|
|
pa.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
|
|
pa.add_argument("--command", help="stdio: executable to run")
|
|
pa.add_argument("--args", help="JSON array of args, e.g. '[\"--root\",\"/tmp\"]'")
|
|
pa.add_argument("--env", help="JSON object of env vars, e.g. '{\"KEY\":\"value\"}'")
|
|
pa.add_argument("--url", help="sse: server URL")
|
|
pa.add_argument("--disabled", action="store_true", help="create disabled")
|
|
pa.set_defaults(func=cmd_add)
|
|
|
|
pd = sub.add_parser("delete", parents=[common])
|
|
pd.add_argument("id")
|
|
pd.set_defaults(func=cmd_delete)
|
|
|
|
return p
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(run(_build_parser()))
|