mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-19 13:47:04 +00:00
c613cfa9a7
Maintainer review on PR #1895 flagged that mcp_server.py duplicated the visibility model from api/routes.py:75. Move the canonical helper into api/profiles.py (next to _is_root_profile, on which it depends) so both api/routes.py and mcp_server.py import the same function instead of carrying parallel definitions that could drift as the model evolves. - api/profiles.py: + _profiles_match (verbatim from former routes.py:75-97) - api/routes.py: replace local definition with re-export to keep all existing _profiles_match(...) call sites resolving without per-call-site refactors - mcp_server.py: drop local copy, import _profiles_match alongside the existing api.profiles imports (line 59) - tests: + test_profiles_match_single_source_of_truth asserts identity (mcp.module._profiles_match is api.profiles._profiles_match is api.routes._profiles_match) so any re-introduction of a local copy trips the test + test_profiles_match_input_matrix parametrize across the (None|''|'default'|'foo') x (None|''|'default'|'foo'|'bar') visibility matrix per maintainer suggestion Behaviour unchanged. Zero call-site changes anywhere in api/routes.py. Co-Authored-By: Claude (Opus 4.7) <noreply@anthropic.com>
568 lines
24 KiB
Python
568 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Hermes WebUI MCP Server — exposes project and session management
|
|
as MCP tools for any MCP-compatible agent.
|
|
|
|
Option A rewrite (2026-05-08): imports api.models and api.profiles
|
|
directly from the webui codebase, using canonical helpers for
|
|
locking, profile scoping, index consistency, and validation.
|
|
|
|
pip install mcp # one-time setup
|
|
python3 mcp_server.py # start via stdio
|
|
|
|
MCP config for Hermes Agent (add to config.yaml):
|
|
mcp_servers:
|
|
hermes-webui:
|
|
command: /path/to/venv/bin/python3
|
|
args: [/path/to/hermes-webui/mcp_server.py]
|
|
env:
|
|
HERMES_WEBUI_PASSWORD: your_password
|
|
|
|
Profile override (optional):
|
|
args: [/path/to/hermes-webui/mcp_server.py, --profile, myprofile]
|
|
|
|
AI-authoring disclosure: this file was rewritten by MILO (Hermes Agent)
|
|
under human direction, per maintainer guidelines for #1616.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from mcp.server import Server
|
|
from mcp.server.stdio import stdio_server
|
|
from mcp.types import Tool, TextContent
|
|
|
|
# ── Ensure the repo root is on sys.path so api.* imports work ─────────────
|
|
_REPO_ROOT = Path(__file__).parent.resolve()
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
# ── CLI: optional --profile override ──────────────────────────────────────
|
|
_profile_arg: str | None = None
|
|
_parser = argparse.ArgumentParser(add_help=False)
|
|
_parser.add_argument("--profile", type=str, default=None)
|
|
_args, _unknown = _parser.parse_known_args()
|
|
_profile_arg = _args.profile
|
|
|
|
# ── Import webui canonical modules (after path setup) ─────────────────────
|
|
import api.config as _cfg
|
|
from api.config import (
|
|
STATE_DIR, SESSION_DIR, SESSION_INDEX_FILE, PROJECTS_FILE, HOME,
|
|
)
|
|
from api.models import load_projects, save_projects
|
|
from api.profiles import get_active_profile_name, _is_root_profile, _profiles_match
|
|
|
|
# ── Apply --profile override before any module uses get_active_profile_name
|
|
if _profile_arg is not None:
|
|
import api.profiles as _profiles
|
|
_profiles._active_profile = _profile_arg
|
|
|
|
# ── API auth state ─────────────────────────────────────────────────────────
|
|
# Mirror the env-var contract used by api/config.py:32-33 so a non-default
|
|
# WebUI port/host (e.g. when 8787 is held by another service on the host)
|
|
# Just Works without configuration drift between the WebUI process and MCP.
|
|
WEBUI_HOST = os.environ.get("HERMES_WEBUI_HOST", "127.0.0.1")
|
|
WEBUI_PORT = os.environ.get("HERMES_WEBUI_PORT", "8787")
|
|
WEBUI_URL = f"http://{WEBUI_HOST}:{WEBUI_PORT}"
|
|
_auth_cookie: str | None = None
|
|
_auth_expires: float = 0 # unix timestamp after which we re-auth
|
|
|
|
server = Server("hermes-webui")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Helpers — filesystem (project CRUD via canonical api.models)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
def _active_profile() -> str:
|
|
"""Shorthand for the current profile name (--profile or auto-detected)."""
|
|
return get_active_profile_name() or 'default'
|
|
|
|
|
|
def _validate_color(color: str | None) -> str | None:
|
|
"""Return an error string if color is invalid, else None."""
|
|
if color is not None and not re.match(r"^#[0-9a-fA-F]{3,8}$", color):
|
|
return "Invalid color format (use #RGB, #RRGGBB, or #RRGGBBAA)"
|
|
return None
|
|
|
|
|
|
def _load_index() -> list:
|
|
"""Read the session index. Falls back to empty list on failure."""
|
|
if not SESSION_INDEX_FILE.exists():
|
|
return []
|
|
try:
|
|
return json.loads(SESSION_INDEX_FILE.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _session_compact(row: dict) -> dict:
|
|
"""Lightweight compact representation of a session index entry."""
|
|
return {
|
|
"session_id": row.get("session_id"),
|
|
"title": row.get("title"),
|
|
"project_id": row.get("project_id"),
|
|
"workspace": row.get("workspace"),
|
|
"model": row.get("model"),
|
|
"message_count": row.get("message_count", 0),
|
|
"source_tag": row.get("source_tag"),
|
|
"is_cli_session": row.get("is_cli_session", False),
|
|
"profile": row.get("profile"),
|
|
}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Helpers — HTTP API (for mutations that need cache sync)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
def _api_password() -> str | None:
|
|
"""Return the plaintext webui password from HERMES_WEBUI_PASSWORD, or None.
|
|
|
|
settings.json stores only the bcrypt hash, which the login endpoint cannot
|
|
accept — it calls verify_password(plaintext) against the stored hash. So
|
|
there's no usable fallback when the env var is unset; the MCP simply runs
|
|
in unauthenticated mode and any auth-protected mutation will fail clearly
|
|
with the server's 401 instead of silently sending an unusable hash.
|
|
"""
|
|
pw = os.environ.get("HERMES_WEBUI_PASSWORD", "").strip()
|
|
return pw or None
|
|
|
|
|
|
def _api_auth() -> str | None:
|
|
"""Authenticate and return cookie value, or None if auth disabled/fails."""
|
|
global _auth_cookie, _auth_expires
|
|
|
|
pw = _api_password()
|
|
if not pw:
|
|
return None # auth not enabled — API calls will fail anyway
|
|
|
|
# Reuse cookie if still valid (25 days — server issues 30-day cookies)
|
|
if _auth_cookie and time.time() < _auth_expires:
|
|
return _auth_cookie
|
|
|
|
import urllib.request
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"{WEBUI_URL}/api/auth/login",
|
|
data=json.dumps({"password": pw}).encode(),
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
resp = urllib.request.urlopen(req, timeout=5)
|
|
cookie = resp.headers.get("Set-Cookie", "")
|
|
if cookie:
|
|
_auth_cookie = cookie.split(";")[0] # "hermes_session=VALUE; ..."
|
|
_auth_expires = time.time() + 25 * 86400 # 25 days
|
|
return _auth_cookie
|
|
except Exception:
|
|
_auth_cookie = None
|
|
return None
|
|
|
|
|
|
def _api_post(endpoint: str, body: dict) -> dict:
|
|
"""POST to webui API with auth cookie. Returns parsed JSON response."""
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
cookie = _api_auth()
|
|
headers = {"Content-Type": "application/json"}
|
|
if cookie:
|
|
headers["Cookie"] = cookie
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"{WEBUI_URL}{endpoint}",
|
|
data=json.dumps(body).encode(),
|
|
headers=headers,
|
|
method="POST",
|
|
)
|
|
resp = urllib.request.urlopen(req, timeout=5)
|
|
return json.loads(resp.read())
|
|
except urllib.error.HTTPError as e:
|
|
err_body = json.loads(e.read())
|
|
return {"error": f"API {e.code}: {err_body.get('error', 'unknown')}"}
|
|
except Exception as e:
|
|
return {"error": f"API unreachable: {e}"}
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Tool handlers — read-only (filesystem, profile-aware)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
async def handle_list_projects(_arguments: dict) -> list[TextContent]:
|
|
"""List all projects with session counts, scoped to active profile."""
|
|
projects = load_projects()
|
|
active = _active_profile()
|
|
index = _load_index()
|
|
|
|
# Session counts per project (from index)
|
|
counts: dict[str, int] = {}
|
|
for s in index:
|
|
pid = s.get("project_id")
|
|
if pid:
|
|
counts[pid] = counts.get(pid, 0) + 1
|
|
|
|
result = []
|
|
for p in projects:
|
|
# Profile filter: legacy untagged rows are treated as 'default' by
|
|
# _profiles_match, so non-root profiles correctly hide them.
|
|
if not _profiles_match(p.get("profile"), active):
|
|
continue
|
|
entry = dict(p)
|
|
entry["session_count"] = counts.get(p["project_id"], 0)
|
|
result.append(entry)
|
|
|
|
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False, indent=2))]
|
|
|
|
|
|
async def handle_list_sessions(arguments: dict) -> list[TextContent]:
|
|
"""List sessions, optionally filtered by project or unassigned status."""
|
|
project_id = arguments.get("project_id")
|
|
unassigned = arguments.get("unassigned", False)
|
|
limit = max(1, min(500, arguments.get("limit", 50)))
|
|
active = _active_profile()
|
|
|
|
index = _load_index()
|
|
sessions = [_session_compact(s) for s in index if s.get("session_id")]
|
|
|
|
# Filter by profile: legacy untagged rows are treated as 'default' by
|
|
# _profiles_match (canonical convention), so non-root profiles hide them.
|
|
sessions = [s for s in sessions if _profiles_match(s.get("profile"), active)]
|
|
|
|
if unassigned:
|
|
sessions = [s for s in sessions if not s["project_id"]]
|
|
elif project_id:
|
|
sessions = [s for s in sessions if s["project_id"] == project_id]
|
|
|
|
sessions = sessions[:limit]
|
|
return [TextContent(type="text", text=json.dumps(sessions, ensure_ascii=False, indent=2))]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Tool handlers — project CRUD (canonical helpers, profile-scoped)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
async def handle_create_project(arguments: dict) -> list[TextContent]:
|
|
"""Create a new project (profile-scoped, exact-match title collision)."""
|
|
name = arguments.get("name", "").strip()[:128]
|
|
if not name:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "name is required"}, ensure_ascii=False))]
|
|
|
|
color = arguments.get("color")
|
|
color_err = _validate_color(color)
|
|
if color_err:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": color_err}, ensure_ascii=False))]
|
|
|
|
active = _active_profile()
|
|
projects = load_projects()
|
|
|
|
# Title collision: exact match (consistent with ensure_cron_project)
|
|
if any(p.get("name") == name and _profiles_match(p.get("profile"), active)
|
|
for p in projects):
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": f"Project '{name}' already exists"}, ensure_ascii=False))]
|
|
|
|
proj = {
|
|
"project_id": uuid.uuid4().hex[:12],
|
|
"name": name,
|
|
"color": color,
|
|
"profile": active,
|
|
"created_at": time.time(),
|
|
}
|
|
projects.append(proj)
|
|
save_projects(projects)
|
|
|
|
proj["session_count"] = 0
|
|
return [TextContent(type="text", text=json.dumps(proj, ensure_ascii=False, indent=2))]
|
|
|
|
|
|
async def handle_rename_project(arguments: dict) -> list[TextContent]:
|
|
"""Rename a project and optionally change its color (profile-checked)."""
|
|
project_id = arguments.get("project_id")
|
|
name = arguments.get("name", "").strip()[:128]
|
|
if not project_id or not name:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "project_id and name are required"}, ensure_ascii=False))]
|
|
|
|
color = arguments.get("color")
|
|
color_err = _validate_color(color)
|
|
if color_err:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": color_err}, ensure_ascii=False))]
|
|
|
|
active = _active_profile()
|
|
projects = load_projects()
|
|
proj = next((p for p in projects if p["project_id"] == project_id), None)
|
|
if not proj:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "Project not found"}, ensure_ascii=False))]
|
|
|
|
# #1614: profile ownership check
|
|
if not _profiles_match(proj.get("profile"), active):
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "Project not found"}, ensure_ascii=False))]
|
|
|
|
proj["name"] = name
|
|
if color is not None:
|
|
proj["color"] = color
|
|
save_projects(projects)
|
|
return [TextContent(type="text", text=json.dumps(proj, ensure_ascii=False, indent=2))]
|
|
|
|
|
|
async def handle_delete_project(arguments: dict) -> list[TextContent]:
|
|
"""Delete a project and unassign all its sessions (profile-checked)."""
|
|
project_id = arguments.get("project_id")
|
|
if not project_id:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "project_id is required"}, ensure_ascii=False))]
|
|
|
|
active = _active_profile()
|
|
projects = load_projects()
|
|
proj = next((p for p in projects if p["project_id"] == project_id), None)
|
|
if not proj:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "Project not found"}, ensure_ascii=False))]
|
|
|
|
# #1614: profile ownership check
|
|
if not _profiles_match(proj.get("profile"), active):
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "Project not found"}, ensure_ascii=False))]
|
|
|
|
projects = [p for p in projects if p["project_id"] != project_id]
|
|
save_projects(projects)
|
|
|
|
# Unassign sessions only when we can do it cache-safely via the HTTP API.
|
|
# The previous filesystem fallback wrote session_data directly with
|
|
# os.replace(), which bypassed _write_session_index() in api/models.py
|
|
# and left _index.json holding the stale project_id — a running WebUI
|
|
# would still group those sessions under the deleted project until a
|
|
# subsequent re-compact. Even calling Session.save() in-process would
|
|
# not help because the WebUI's SESSIONS dict cache (a separate process)
|
|
# still has the old project_id and overwrites our update on its next
|
|
# save. The HTTP API is the only cache-safe path; without auth we
|
|
# refuse and surface the limitation so the operator can act.
|
|
has_auth = bool(_api_password())
|
|
if not has_auth:
|
|
return [TextContent(type="text", text=json.dumps({
|
|
"ok": True,
|
|
"deleted": proj["name"],
|
|
"unassigned_sessions": 0,
|
|
"warning": "Set HERMES_WEBUI_PASSWORD to unassign sessions; "
|
|
"without auth the session index cannot be safely "
|
|
"updated and direct filesystem writes would cause "
|
|
"index drift in a running WebUI.",
|
|
}, ensure_ascii=False))]
|
|
|
|
unassigned = 0
|
|
if SESSION_DIR.exists():
|
|
for p in SESSION_DIR.glob("*.json"):
|
|
if p.name.startswith("_"):
|
|
continue
|
|
try:
|
|
session_data = json.loads(p.read_text(encoding="utf-8"))
|
|
if session_data.get("project_id") == project_id:
|
|
sid = p.stem
|
|
result = _api_post("/api/session/move",
|
|
{"session_id": sid, "project_id": None})
|
|
if "ok" in result or "session" in result:
|
|
unassigned += 1
|
|
except Exception:
|
|
pass
|
|
|
|
return [TextContent(type="text", text=json.dumps({
|
|
"ok": True,
|
|
"deleted": proj["name"],
|
|
"unassigned_sessions": unassigned,
|
|
}, ensure_ascii=False))]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Tool handlers — mutations (HTTP API with auth, cache-safe)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
async def handle_rename_session(arguments: dict) -> list[TextContent]:
|
|
"""Rename a session via the authenticated webui API (cache-safe)."""
|
|
session_id = arguments.get("session_id")
|
|
title = arguments.get("title", "").strip()[:80]
|
|
if not session_id or not title:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "session_id and title are required"}, ensure_ascii=False))]
|
|
|
|
result = _api_post("/api/session/rename",
|
|
{"session_id": session_id, "title": title})
|
|
if "error" in result:
|
|
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
|
|
session = result.get("session", {})
|
|
return [TextContent(type="text", text=json.dumps({
|
|
"ok": True,
|
|
"session_id": session_id,
|
|
"title": session.get("title", title),
|
|
"method": "api",
|
|
}, ensure_ascii=False, indent=2))]
|
|
|
|
|
|
async def handle_move_session(arguments: dict) -> list[TextContent]:
|
|
"""Assign a session to a project via the authenticated webui API (cache-safe)."""
|
|
session_id = arguments.get("session_id")
|
|
project_id = arguments.get("project_id") # None/null = unassign
|
|
if not session_id:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "session_id is required"}, ensure_ascii=False))]
|
|
|
|
# If project_id is provided, verify it exists and is profile-accessible
|
|
if project_id is not None:
|
|
projects = load_projects()
|
|
active = _active_profile()
|
|
target = next((p for p in projects if p["project_id"] == project_id), None)
|
|
if not target:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "Project not found"}, ensure_ascii=False))]
|
|
# #1614: refuse moves into projects owned by another profile
|
|
if not _profiles_match(target.get("profile"), active):
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": "Project not found"}, ensure_ascii=False))]
|
|
|
|
result = _api_post("/api/session/move",
|
|
{"session_id": session_id, "project_id": project_id})
|
|
if "error" in result:
|
|
return [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
|
|
|
|
session = result.get("session", {})
|
|
return [TextContent(type="text", text=json.dumps({
|
|
"ok": True,
|
|
"session_id": session_id,
|
|
"project_id": project_id,
|
|
"title": session.get("title"),
|
|
"method": "api",
|
|
}, ensure_ascii=False, indent=2))]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# MCP Server wiring
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
TOOLS = [
|
|
Tool(
|
|
name="list_projects",
|
|
description="List all session projects with their IDs, names, colors, and session counts (scoped to active profile).",
|
|
inputSchema={"type": "object", "properties": {}, "required": []},
|
|
),
|
|
Tool(
|
|
name="create_project",
|
|
description="Create a new project for organizing sessions (profile-scoped).",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "description": "Project name (max 128 chars)"},
|
|
"color": {"type": "string", "description": "Optional hex color (#RGB, #RRGGBB, or #RRGGBBAA)"},
|
|
},
|
|
"required": ["name"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="rename_project",
|
|
description="Rename a project and optionally change its color (profile-checked).",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"project_id": {"type": "string", "description": "12-char project ID"},
|
|
"name": {"type": "string", "description": "New name (max 128 chars)"},
|
|
"color": {"type": "string", "description": "Optional new hex color"},
|
|
},
|
|
"required": ["project_id", "name"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="delete_project",
|
|
description="Delete a project and unassign all its sessions (profile-checked).",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"project_id": {"type": "string", "description": "12-char project ID to delete"},
|
|
},
|
|
"required": ["project_id"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="rename_session",
|
|
description="Rename a session (updates sidebar via authenticated API, cache-safe).",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"session_id": {"type": "string", "description": "Session ID"},
|
|
"title": {"type": "string", "description": "New title (max 80 chars)"},
|
|
},
|
|
"required": ["session_id", "title"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="move_session",
|
|
description="Assign a session to a project. Pass project_id=null to unassign. Uses authenticated API for cache safety (profile-checked).",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"session_id": {"type": "string", "description": "Session ID"},
|
|
"project_id": {"type": ["string", "null"], "description": "Project ID (or null to unassign)"},
|
|
},
|
|
"required": ["session_id", "project_id"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="list_sessions",
|
|
description="List sessions, optionally filtered by project or unassigned status (profile-scoped).",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"project_id": {"type": "string", "description": "Filter sessions by project ID"},
|
|
"unassigned": {"type": "boolean", "description": "Show only sessions with no project"},
|
|
"limit": {"type": "integer", "description": "Max results (default: 50, max: 500)"},
|
|
},
|
|
"required": [],
|
|
},
|
|
),
|
|
]
|
|
|
|
HANDLERS = {
|
|
"list_projects": handle_list_projects,
|
|
"create_project": handle_create_project,
|
|
"rename_project": handle_rename_project,
|
|
"delete_project": handle_delete_project,
|
|
"rename_session": handle_rename_session,
|
|
"move_session": handle_move_session,
|
|
"list_sessions": handle_list_sessions,
|
|
}
|
|
|
|
|
|
@server.list_tools()
|
|
async def list_tools() -> list[Tool]:
|
|
return TOOLS
|
|
|
|
|
|
@server.call_tool()
|
|
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
handler = HANDLERS.get(name)
|
|
if not handler:
|
|
return [TextContent(type="text", text=json.dumps(
|
|
{"error": f"Unknown tool: {name}"}, ensure_ascii=False))]
|
|
return await handler(arguments)
|
|
|
|
|
|
async def main():
|
|
async with stdio_server() as (read, write):
|
|
await server.run(read, write, server.create_initialization_options())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import asyncio
|
|
asyncio.run(main())
|