voodoo-framework 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
voodoo/core.py ADDED
@@ -0,0 +1,236 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import importlib.util
5
+ from typing import Dict, Any, Callable
6
+ from starlette.applications import Starlette
7
+ from starlette.routing import Route, WebSocketRoute, Mount
8
+ from starlette.responses import HTMLResponse, Response
9
+ from starlette.websockets import WebSocket
10
+ from starlette.staticfiles import StaticFiles
11
+ import inspect
12
+
13
+ class WebSocketManager:
14
+ def __init__(self):
15
+ self.active_connections: list[WebSocket] = []
16
+
17
+ async def connect(self, websocket: WebSocket):
18
+ await websocket.accept()
19
+ self.active_connections.append(websocket)
20
+
21
+ def disconnect(self, websocket: WebSocket):
22
+ if websocket in self.active_connections:
23
+ self.active_connections.remove(websocket)
24
+
25
+ async def broadcast_patch(self, element_id: str, html: str):
26
+ message = json.dumps({"type": "patch", "id": element_id, "html": html})
27
+ for connection in self.active_connections:
28
+ try:
29
+ await connection.send_text(message)
30
+ except Exception:
31
+ pass
32
+
33
+ async def broadcast_append(self, element_id: str, html: str):
34
+ message = json.dumps({"type": "append", "id": element_id, "html": html})
35
+ for connection in self.active_connections:
36
+ try:
37
+ await connection.send_text(message)
38
+ except Exception:
39
+ pass
40
+
41
+ ws_manager = WebSocketManager()
42
+ event_handlers: Dict[str, Callable] = {}
43
+
44
+ def register_event(name: str, handler: Callable):
45
+ event_handlers[name] = handler
46
+
47
+ async def websocket_endpoint(websocket: WebSocket):
48
+ print(f"WS Connecting: {websocket.headers}")
49
+ await ws_manager.connect(websocket)
50
+ print("WS Connected!")
51
+ try:
52
+ while True:
53
+ data = await websocket.receive_text()
54
+ print(f"WS Received: {data}")
55
+ msg = json.loads(data)
56
+ if msg.get("type") == "event":
57
+ handler = event_handlers.get(msg["event"])
58
+ if handler:
59
+ if inspect.iscoroutinefunction(handler):
60
+ await handler(msg["id"], msg["value"])
61
+ else:
62
+ handler(msg["id"], msg["value"])
63
+ except Exception as e:
64
+ print(f"WS Disconnected/Error: {e}")
65
+ ws_manager.disconnect(websocket)
66
+
67
+ def render_page(component) -> str:
68
+ from voodoo.components import Component
69
+ from voodoo.theme import default_theme
70
+
71
+ html_content = component.render() if isinstance(component, Component) else str(component)
72
+
73
+ # Read client.js
74
+ client_js_path = os.path.join(os.path.dirname(__file__), "client.js")
75
+ with open(client_js_path, "r") as f:
76
+ client_js = f.read()
77
+
78
+ tailwind_config = default_theme.to_tailwind_config()
79
+ css_vars = default_theme.to_css_variables()
80
+
81
+ # Check for theme cookie if available via a simple hack or rely on client side script
82
+ # Better: handle it strictly on client side using JS on load to prevent flash of wrong theme
83
+
84
+ html_class = f"dark {default_theme.mode}" if default_theme.mode == "dark" else default_theme.mode
85
+
86
+ return f"""
87
+ <!DOCTYPE html>
88
+ <html lang="en" class="{html_class}">
89
+ <head>
90
+ <meta charset="UTF-8">
91
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
92
+ <title>Voodoo App</title>
93
+ <script src="https://cdn.tailwindcss.com"></script>
94
+ <script>
95
+ tailwind.config = {tailwind_config};
96
+
97
+ // Prevent flash of incorrect theme
98
+ if (document.cookie.includes('voodoo_theme=light')) {{
99
+ document.documentElement.classList.remove('dark');
100
+ document.documentElement.classList.add('light');
101
+ }} else if (document.cookie.includes('voodoo_theme=dark')) {{
102
+ document.documentElement.classList.remove('light');
103
+ document.documentElement.classList.add('dark');
104
+ }}
105
+ </script>
106
+ <style>
107
+ {css_vars}
108
+ body {{
109
+ background-color: var(--color-background);
110
+ color: var(--color-text);
111
+ font-family: var(--font-sans);
112
+ }}
113
+ ::-webkit-scrollbar {{ width: 8px; height: 8px; }}
114
+ ::-webkit-scrollbar-track {{ background: transparent; }}
115
+ ::-webkit-scrollbar-thumb {{ background: var(--color-surface); border-radius: 4px; border: 1px solid var(--color-border); }}
116
+ ::-webkit-scrollbar-thumb:hover {{ background: var(--color-text-muted); }}
117
+ </style>
118
+ </head>
119
+ <body class="bg-[var(--color-background)] text-[var(--color-text)] min-h-screen antialiased selection:bg-[var(--color-secondary)] selection:text-white">
120
+ <div id="root">
121
+ {html_content}
122
+ </div>
123
+ <script>
124
+ {client_js}
125
+ </script>
126
+ </body>
127
+ </html>
128
+ """
129
+
130
+ def create_app(app_dir: str = "app") -> Starlette:
131
+ from voodoo.config import config
132
+ base_storage_dir = os.path.join(os.getcwd(), config.storage_dir)
133
+ public_storage_dir = os.path.join(base_storage_dir, "public")
134
+ os.makedirs(public_storage_dir, exist_ok=True)
135
+
136
+ routes = [
137
+ WebSocketRoute("/_voodoo_ws", websocket_endpoint),
138
+ Mount("/storage/public", app=StaticFiles(directory=public_storage_dir), name="storage_public")
139
+ ]
140
+
141
+ # Simple file-based router
142
+ pages_dir = os.path.join(app_dir, "pages")
143
+ if os.path.exists(pages_dir):
144
+ for filename in os.listdir(pages_dir):
145
+ if filename.endswith(".py") and not filename.startswith("_"):
146
+ name = filename[:-3]
147
+ route_path = "/" if name == "index" else f"/{name}"
148
+ filepath = os.path.join(pages_dir, filename)
149
+ spec = importlib.util.spec_from_file_location(f"page_{name}", filepath)
150
+ if spec and spec.loader:
151
+ page_module = importlib.util.module_from_spec(spec)
152
+ spec.loader.exec_module(page_module)
153
+ if hasattr(page_module, "page"):
154
+ # create a closure to capture the module
155
+ def make_route(mod):
156
+ async def handler(request):
157
+ sig = inspect.signature(mod.page)
158
+ if "request" in sig.parameters:
159
+ component = mod.page(request)
160
+ else:
161
+ component = mod.page()
162
+ if inspect.iscoroutine(component):
163
+ component = await component
164
+ return HTMLResponse(render_page(component))
165
+ return handler
166
+ routes.append(Route(route_path, make_route(page_module)))
167
+ else:
168
+ page_path = os.path.join(app_dir, "page.py")
169
+ if os.path.exists(page_path):
170
+ spec = importlib.util.spec_from_file_location("page", page_path)
171
+ if spec and spec.loader:
172
+ page_module = importlib.util.module_from_spec(spec)
173
+ spec.loader.exec_module(page_module)
174
+
175
+ if hasattr(page_module, "page"):
176
+ async def index(request):
177
+ sig = inspect.signature(page_module.page)
178
+ if "request" in sig.parameters:
179
+ component = page_module.page(request)
180
+ else:
181
+ component = page_module.page()
182
+ if inspect.iscoroutine(component):
183
+ component = await component
184
+ return HTMLResponse(render_page(component))
185
+ routes.append(Route("/", index))
186
+
187
+ # Initialize components if needed
188
+ models_path = os.path.join(app_dir, "models.py")
189
+ if os.path.exists(models_path):
190
+ spec = importlib.util.spec_from_file_location("models", models_path)
191
+ if spec and spec.loader:
192
+ models_module = importlib.util.module_from_spec(spec)
193
+ spec.loader.exec_module(models_module)
194
+
195
+ workers_path = os.path.join(app_dir, "workers.py")
196
+ if os.path.exists(workers_path):
197
+ spec = importlib.util.spec_from_file_location("workers", workers_path)
198
+ if spec and spec.loader:
199
+ workers_module = importlib.util.module_from_spec(spec)
200
+ spec.loader.exec_module(workers_module)
201
+
202
+ api_path = os.path.join(app_dir, "api.py")
203
+ if os.path.exists(api_path):
204
+ spec = importlib.util.spec_from_file_location("api", api_path)
205
+ if spec and spec.loader:
206
+ api_module = importlib.util.module_from_spec(spec)
207
+ spec.loader.exec_module(api_module)
208
+
209
+ from voodoo.api import api as voodoo_api
210
+ routes.extend(voodoo_api.routes)
211
+
212
+ from voodoo.queue import start_workers, stop_workers
213
+ from voodoo.data import init_db
214
+ from voodoo.telemetry import TelemetryMiddleware
215
+ from voodoo.i18n import I18nMiddleware
216
+ from starlette.middleware import Middleware
217
+ from contextlib import asynccontextmanager
218
+
219
+ @asynccontextmanager
220
+ async def lifespan(app: Starlette):
221
+ # Startup
222
+ await init_db()
223
+ worker_task = asyncio.create_task(start_workers())
224
+ yield
225
+ # Shutdown
226
+ await stop_workers()
227
+ worker_task.cancel()
228
+
229
+ middleware = [
230
+ Middleware(TelemetryMiddleware),
231
+ Middleware(I18nMiddleware)
232
+ ]
233
+
234
+ app = Starlette(debug=True, routes=routes, middleware=middleware, lifespan=lifespan)
235
+
236
+ return app
voodoo/data.py ADDED
@@ -0,0 +1,179 @@
1
+ import asyncio
2
+ import aiosqlite
3
+ from typing import Any, Callable, Dict, List, Type, get_type_hints
4
+
5
+ _db_connection = None
6
+ _triggers: Dict[str, Dict[str, List[Callable]]] = {}
7
+ _rls_policies: Dict[str, Callable] = {}
8
+
9
+ import os
10
+
11
+ async def init_db(db_path: str = None):
12
+ if db_path is None:
13
+ from voodoo.config import config
14
+ db_path = config.db_path
15
+
16
+ db_dir = os.path.dirname(db_path)
17
+ if db_dir:
18
+ os.makedirs(db_dir, exist_ok=True)
19
+
20
+ global _db_connection
21
+ _db_connection = await aiosqlite.connect(db_path)
22
+ _db_connection.row_factory = aiosqlite.Row
23
+ for model in _models:
24
+ await model._create_table()
25
+
26
+ async def get_db():
27
+ if _db_connection is None:
28
+ await init_db()
29
+ return _db_connection
30
+
31
+ def on_insert(model_cls: Type):
32
+ def decorator(func: Callable):
33
+ table = model_cls.__name__.lower()
34
+ if table not in _triggers:
35
+ _triggers[table] = {"insert": [], "update": []}
36
+ _triggers[table]["insert"].append(func)
37
+ return func
38
+ return decorator
39
+
40
+ def on_update(model_cls: Type):
41
+ def decorator(func: Callable):
42
+ table = model_cls.__name__.lower()
43
+ if table not in _triggers:
44
+ _triggers[table] = {"insert": [], "update": []}
45
+ _triggers[table]["update"].append(func)
46
+ return func
47
+ return decorator
48
+
49
+ def rls_policy(model_cls: Type):
50
+ def decorator(func: Callable):
51
+ table = model_cls.__name__.lower()
52
+ _rls_policies[table] = func
53
+ return func
54
+ return decorator
55
+
56
+ _models: List[Type] = []
57
+
58
+ class ModelMeta(type):
59
+ def __init__(cls, name, bases, attrs):
60
+ super().__init__(name, bases, attrs)
61
+ if name != "BaseModel":
62
+ _models.append(cls)
63
+
64
+ class BaseModel(metaclass=ModelMeta):
65
+ id: int
66
+
67
+ @classmethod
68
+ async def _create_table(cls):
69
+ db = await get_db()
70
+ table_name = cls.__name__.lower()
71
+ hints = get_type_hints(cls)
72
+ columns = []
73
+ for col_name, col_type in hints.items():
74
+ if col_name == "id":
75
+ columns.append("id INTEGER PRIMARY KEY AUTOINCREMENT")
76
+ elif col_type == int:
77
+ columns.append(f"{col_name} INTEGER")
78
+ elif col_type == str:
79
+ columns.append(f"{col_name} TEXT")
80
+ elif col_type == bool:
81
+ columns.append(f"{col_name} BOOLEAN")
82
+ else:
83
+ columns.append(f"{col_name} TEXT")
84
+
85
+ query = f"CREATE TABLE IF NOT EXISTS {table_name} ({', '.join(columns)})"
86
+ await db.execute(query)
87
+ await db.commit()
88
+
89
+ @classmethod
90
+ async def find_all(cls, user_context: dict = None) -> List['BaseModel']:
91
+ from voodoo.telemetry import telemetry_store
92
+ telemetry_store.record_db_query()
93
+ db = await get_db()
94
+ table_name = cls.__name__.lower()
95
+ query = f"SELECT * FROM {table_name}"
96
+ params = []
97
+
98
+ if table_name in _rls_policies and user_context:
99
+ policy = _rls_policies[table_name]
100
+ where_clause = policy(user_context)
101
+ if where_clause:
102
+ query += f" WHERE {where_clause}"
103
+
104
+ async with db.execute(query, params) as cursor:
105
+ rows = await cursor.fetchall()
106
+ results = []
107
+ hints = get_type_hints(cls)
108
+ for row in rows:
109
+ obj = cls()
110
+ for k in row.keys():
111
+ val = row[k]
112
+ if k in hints and hints[k] == bool:
113
+ val = bool(val)
114
+ setattr(obj, k, val)
115
+ results.append(obj)
116
+ return results
117
+
118
+ async def insert(self):
119
+ from voodoo.telemetry import telemetry_store
120
+ telemetry_store.record_db_query()
121
+ db = await get_db()
122
+ table_name = self.__class__.__name__.lower()
123
+
124
+ hints = get_type_hints(self.__class__)
125
+ cols = []
126
+ vals = []
127
+ placeholders = []
128
+
129
+ for col_name in hints.keys():
130
+ if col_name == "id" and not hasattr(self, "id"):
131
+ continue
132
+ if hasattr(self, col_name):
133
+ cols.append(col_name)
134
+ vals.append(getattr(self, col_name))
135
+ placeholders.append("?")
136
+
137
+ query = f"INSERT INTO {table_name} ({', '.join(cols)}) VALUES ({', '.join(placeholders)})"
138
+ cursor = await db.execute(query, vals)
139
+ await db.commit()
140
+
141
+ self.id = cursor.lastrowid
142
+
143
+ # Trigger hooks
144
+ if table_name in _triggers and _triggers[table_name]["insert"]:
145
+ for hook in _triggers[table_name]["insert"]:
146
+ if asyncio.iscoroutinefunction(hook):
147
+ asyncio.create_task(hook(self))
148
+ else:
149
+ hook(self)
150
+ return self
151
+
152
+ async def update(self):
153
+ from voodoo.telemetry import telemetry_store
154
+ telemetry_store.record_db_query()
155
+ db = await get_db()
156
+ table_name = self.__class__.__name__.lower()
157
+
158
+ hints = get_type_hints(self.__class__)
159
+ cols = []
160
+ vals = []
161
+
162
+ for col_name in hints.keys():
163
+ if col_name != "id" and hasattr(self, col_name):
164
+ cols.append(f"{col_name} = ?")
165
+ vals.append(getattr(self, col_name))
166
+
167
+ vals.append(self.id)
168
+ query = f"UPDATE {table_name} SET {', '.join(cols)} WHERE id = ?"
169
+ await db.execute(query, vals)
170
+ await db.commit()
171
+
172
+ # Trigger hooks
173
+ if table_name in _triggers and _triggers[table_name]["update"]:
174
+ for hook in _triggers[table_name]["update"]:
175
+ if asyncio.iscoroutinefunction(hook):
176
+ asyncio.create_task(hook(self))
177
+ else:
178
+ hook(self)
179
+ return self
voodoo/i18n.py ADDED
@@ -0,0 +1,107 @@
1
+ import os
2
+ import json
3
+ from contextvars import ContextVar
4
+ from typing import Dict, Any, Optional
5
+ from starlette.middleware.base import BaseHTTPMiddleware
6
+ from starlette.requests import Request
7
+
8
+ # Context variables for the current request's language and translations
9
+ current_language: ContextVar[str] = ContextVar("current_language", default="en")
10
+ translations: ContextVar[Dict[str, Any]] = ContextVar("translations", default={})
11
+
12
+ class I18n:
13
+ """Internationalization manager for Voodoo."""
14
+ def __init__(self, default_lang: str = "en", locales_dir: str = "locales"):
15
+ self.default_lang = default_lang
16
+ self.locales_dir = locales_dir
17
+ self.locales: Dict[str, Dict[str, Any]] = {}
18
+ self.load_locales()
19
+
20
+ def load_locales(self):
21
+ """Loads JSON locale files from the locales directory."""
22
+ if not os.path.exists(self.locales_dir):
23
+ return
24
+
25
+ for filename in os.listdir(self.locales_dir):
26
+ if filename.endswith(".json"):
27
+ lang = filename[:-5]
28
+ filepath = os.path.join(self.locales_dir, filename)
29
+ try:
30
+ with open(filepath, "r", encoding="utf-8") as f:
31
+ self.locales[lang] = json.load(f)
32
+ except Exception as e:
33
+ print(f"Error loading locale {lang}: {e}")
34
+
35
+ def get_translation(self, lang: str, key: str, **kwargs) -> str:
36
+ """Retrieves a translation string by key for a specific language."""
37
+ lang_dict = self.locales.get(lang) or self.locales.get(self.default_lang) or {}
38
+
39
+ # Support nested keys like "home.title"
40
+ keys = key.split(".")
41
+ val = lang_dict
42
+ for k in keys:
43
+ if isinstance(val, dict):
44
+ val = val.get(k)
45
+ else:
46
+ val = None
47
+ break
48
+
49
+ if val is None:
50
+ return key # fallback to key if not found
51
+
52
+ if isinstance(val, str) and kwargs:
53
+ try:
54
+ return val.format(**kwargs)
55
+ except KeyError:
56
+ return val
57
+
58
+ return str(val)
59
+
60
+ # Global i18n instance
61
+ i18n_instance = I18n()
62
+
63
+ def _(key: str, **kwargs) -> str:
64
+ """
65
+ Translates a key using the current request's language.
66
+ This is the main function to be used in components and pages.
67
+ """
68
+ lang = current_language.get()
69
+ return i18n_instance.get_translation(lang, key, **kwargs)
70
+
71
+ class I18nMiddleware(BaseHTTPMiddleware):
72
+ """
73
+ Middleware to detect and set the language for the current request.
74
+ Priority: Query Param (?lang=) > Cookie (voodoo_lang) > Accept-Language Header > Default
75
+ """
76
+ def __init__(self, app, i18n: Optional[I18n] = None):
77
+ super().__init__(app)
78
+ self.i18n = i18n or i18n_instance
79
+
80
+ async def dispatch(self, request: Request, call_next):
81
+ # 1. Query Param
82
+ lang = request.query_params.get("lang")
83
+
84
+ # 2. Cookie
85
+ if not lang:
86
+ lang = request.cookies.get("voodoo_lang")
87
+
88
+ # 3. Accept-Language Header
89
+ if not lang:
90
+ accept_lang = request.headers.get("accept-language")
91
+ if accept_lang:
92
+ # e.g., "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7"
93
+ lang = accept_lang.split(",")[0].split("-")[0]
94
+
95
+ if not lang or lang not in self.i18n.locales:
96
+ lang = self.i18n.default_lang
97
+
98
+ # Set the context variable for the duration of this request
99
+ token = current_language.set(lang)
100
+ try:
101
+ response = await call_next(request)
102
+ # If lang was set via query, we might want to set a cookie for future requests
103
+ if request.query_params.get("lang"):
104
+ response.set_cookie("voodoo_lang", lang)
105
+ return response
106
+ finally:
107
+ current_language.reset(token)
voodoo/mcp.py ADDED
@@ -0,0 +1,141 @@
1
+ import inspect
2
+ import asyncio
3
+ from typing import Any, Callable, Dict, Optional
4
+ from voodoo.api import api
5
+ from starlette.requests import Request
6
+ from starlette.responses import JSONResponse, StreamingResponse
7
+
8
+ class MCPServer:
9
+ def __init__(self, name: str = "voodoo-mcp", version: str = "1.0.0"):
10
+ self.name = name
11
+ self.version = version
12
+ self.tools: Dict[str, Dict[str, Any]] = {}
13
+ self.resources: Dict[str, Dict[str, Any]] = {}
14
+
15
+ # Register SSE endpoints
16
+ api.get("/mcp/sse")(self._sse_endpoint)
17
+ api.post("/mcp/messages")(self._messages_endpoint)
18
+
19
+ def tool(self, name: Optional[str] = None, description: Optional[str] = None):
20
+ def decorator(func: Callable):
21
+ tool_name = name or func.__name__
22
+ self.tools[tool_name] = {
23
+ "func": func,
24
+ "description": description or func.__doc__ or "No description provided."
25
+ }
26
+ return func
27
+ return decorator
28
+
29
+ def resource(self, uri: str, name: Optional[str] = None):
30
+ def decorator(func: Callable):
31
+ res_name = name or func.__name__
32
+ self.resources[uri] = {
33
+ "func": func,
34
+ "name": res_name
35
+ }
36
+ return func
37
+ return decorator
38
+
39
+ async def _sse_endpoint(self, request: Request):
40
+ async def event_generator():
41
+ yield 'event: endpoint\ndata: /mcp/messages\n\n'
42
+ while True:
43
+ await asyncio.sleep(15)
44
+ yield ':\n\n'
45
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
46
+
47
+ async def _messages_endpoint(self, request: Request):
48
+ try:
49
+ body = await request.json()
50
+ method = body.get("method")
51
+ params = body.get("params", {})
52
+ msg_id = body.get("id")
53
+
54
+ if method == "tools/list":
55
+ tools_list = [
56
+ {
57
+ "name": t_name,
58
+ "description": t_data["description"],
59
+ "inputSchema": {
60
+ "type": "object",
61
+ "properties": {}
62
+ }
63
+ }
64
+ for t_name, t_data in self.tools.items()
65
+ ]
66
+ return JSONResponse({
67
+ "jsonrpc": "2.0",
68
+ "id": msg_id,
69
+ "result": {"tools": tools_list}
70
+ })
71
+
72
+ elif method == "tools/call":
73
+ tool_name = params.get("name")
74
+ args = params.get("arguments", {})
75
+ if tool_name in self.tools:
76
+ func = self.tools[tool_name]["func"]
77
+ if inspect.iscoroutinefunction(func):
78
+ result = await func(**args)
79
+ else:
80
+ result = func(**args)
81
+ return JSONResponse({
82
+ "jsonrpc": "2.0",
83
+ "id": msg_id,
84
+ "result": {"content": [{"type": "text", "text": str(result)}]}
85
+ })
86
+ else:
87
+ return JSONResponse({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "Tool not found"}})
88
+
89
+ elif method == "resources/list":
90
+ res_list = [
91
+ {
92
+ "uri": uri,
93
+ "name": r_data["name"]
94
+ }
95
+ for uri, r_data in self.resources.items()
96
+ ]
97
+ return JSONResponse({
98
+ "jsonrpc": "2.0",
99
+ "id": msg_id,
100
+ "result": {"resources": res_list}
101
+ })
102
+
103
+ elif method == "resources/read":
104
+ uri = params.get("uri")
105
+ if uri in self.resources:
106
+ func = self.resources[uri]["func"]
107
+ if inspect.iscoroutinefunction(func):
108
+ content = await func()
109
+ else:
110
+ content = func()
111
+ return JSONResponse({
112
+ "jsonrpc": "2.0",
113
+ "id": msg_id,
114
+ "result": {"contents": [{"uri": uri, "text": str(content)}]}
115
+ })
116
+ return JSONResponse({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32602, "message": "Resource not found"}})
117
+
118
+ return JSONResponse({"jsonrpc": "2.0", "id": msg_id, "error": {"code": -32601, "message": "Method not found"}})
119
+ except Exception as e:
120
+ return JSONResponse({"jsonrpc": "2.0", "error": {"code": -32603, "message": str(e)}}, status_code=500)
121
+
122
+ class MCPClient:
123
+ def __init__(self, endpoint_url: str):
124
+ self.endpoint_url = endpoint_url
125
+
126
+ async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Any:
127
+ import httpx
128
+ async with httpx.AsyncClient() as client:
129
+ payload = {
130
+ "jsonrpc": "2.0",
131
+ "method": "tools/call",
132
+ "params": {
133
+ "name": name,
134
+ "arguments": arguments
135
+ },
136
+ "id": 1
137
+ }
138
+ response = await client.post(self.endpoint_url, json=payload)
139
+ return response.json()
140
+
141
+ mcp = MCPServer()