mwtuni commited on
Commit
857ffa3
·
1 Parent(s): 56dfc83

improved MCP tooling with schemas

Browse files
Files changed (9) hide show
  1. .vscode/mcp.json +9 -0
  2. .vscode/settings.json +10 -0
  3. README.md +4 -0
  4. api.py +431 -7
  5. app.py +1 -1
  6. requirements.txt +1 -0
  7. run.sh +1 -1
  8. test_mcp_http.py +29 -0
  9. test_mcp_ws.py +21 -0
.vscode/mcp.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "servers": {
3
+ "avatar": {
4
+ "url": "http://localhost:7866/mcp/http",
5
+ "type": "http"
6
+ }
7
+ },
8
+ "inputs": []
9
+ }
.vscode/settings.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "github.copilot.mcpServers": {
3
+ "avatar-mcp": {
4
+ "title": "Avatar MCP (local)",
5
+ "transport": "websocket",
6
+ "url": "ws://localhost:7866/mcp/ws",
7
+ "description": "Connects Copilot to the local Avatar MCP FastAPI server."
8
+ }
9
+ }
10
+ }
README.md CHANGED
@@ -40,6 +40,10 @@ uvicorn app:app --host 0.0.0.0 --port 7860
40
  - `POST /mcp/generate_image` — `{avatar_id, message/reply}` → generates image using portrait + context.
41
  - Memory/portrait management: `/mcp/store_avatar_memory`, `/mcp/get_avatar`, `/mcp/get_avatar_context`, `/mcp/delete_avatar_memory`, `/mcp/delete_generated_images`, `/mcp/set_avatar_portrait`, `/mcp/delete_avatar_portrait`.
42
 
 
 
 
 
43
  ## Storage
44
  - Avatars: `avatars/<id>/avatar.json`
45
  - Portrait: `avatars/<id>/portrait.png`
 
40
  - `POST /mcp/generate_image` — `{avatar_id, message/reply}` → generates image using portrait + context.
41
  - Memory/portrait management: `/mcp/store_avatar_memory`, `/mcp/get_avatar`, `/mcp/get_avatar_context`, `/mcp/delete_avatar_memory`, `/mcp/delete_generated_images`, `/mcp/set_avatar_portrait`, `/mcp/delete_avatar_portrait`.
42
 
43
+ ### MCP transports
44
+ - **WebSocket:** `ws://<host>:<port>/mcp/ws` (default local dev: `ws://localhost:7866/mcp/ws`). On connect you receive `{"type":"welcome","tools":[...]}`; send `{id, tool, payload}` frames to invoke.
45
+ - **HTTP:** `GET /mcp/http` lists tools. `POST /mcp/http` now understands both the legacy `{"tool": "<name>", "payload": {...}}` calls (used by the bundled smoke tests) and the official MCP JSON-RPC 2.0 flow (`initialize`, `tools/list`, `tools/call`, ... using protocol `2025-06-18`) for VS Code / Claude style hosts.
46
+
47
  ## Storage
48
  - Avatars: `avatars/<id>/avatar.json`
49
  - Portrait: `avatars/<id>/portrait.png`
api.py CHANGED
@@ -1,9 +1,12 @@
 
1
  import os
2
  from datetime import datetime
3
  from pathlib import Path
 
4
 
5
- from fastapi import FastAPI, HTTPException
6
  from fastapi.middleware.cors import CORSMiddleware
 
7
 
8
  from avatar_store import avatar_generated_path
9
  from generate_image_with_nano import build_prompt, run_edit
@@ -32,6 +35,187 @@ fastapi_app.add_middleware(
32
  allow_headers=["*"],
33
  )
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
  def _handle(func, payload):
37
  try:
@@ -49,6 +233,57 @@ def _resolve_path(path_str):
49
  return str(path)
50
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  @fastapi_app.post("/mcp/create_avatar")
53
  def api_create_avatar(payload: dict):
54
  return _handle(create_avatar, payload)
@@ -64,22 +299,21 @@ def api_generate_as_avatar(payload: dict):
64
  return _handle(generate_as_avatar, payload)
65
 
66
 
67
- @fastapi_app.post("/mcp/generate_image")
68
- def api_generate_image(payload: dict):
69
  payload = payload or {}
70
  avatar_id = payload.get("avatar_id")
71
  if not avatar_id:
72
- raise HTTPException(status_code=400, detail="avatar_id required")
73
  message = (payload.get("message") or "").strip()
74
  reply = (payload.get("reply") or "").strip()
75
  context = message or reply or "scene with the avatar"
76
  try:
77
  avatar = ensure_public_avatar(avatar_id)
78
  except ValueError as exc:
79
- raise HTTPException(status_code=400, detail=str(exc))
80
  portrait_path = _resolve_path(avatar.get("portrait"))
81
  if not portrait_path or not Path(portrait_path).exists():
82
- raise HTTPException(status_code=400, detail="portrait not found for avatar")
83
  prompt = build_prompt(avatar.get("persona", "Avatar"), context)
84
  timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
85
  out_path = avatar_generated_path(avatar_id, timestamp)
@@ -89,7 +323,7 @@ def api_generate_image(payload: dict):
89
  except SystemExit:
90
  rc = 1
91
  if rc != 0:
92
- raise HTTPException(status_code=500, detail="image generation failed")
93
  record_generated_image(avatar_id, out_path, prompt, timestamp)
94
  return {
95
  "status": "generated",
@@ -99,6 +333,121 @@ def api_generate_image(payload: dict):
99
  }
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  @fastapi_app.post("/mcp/store_avatar_memory")
103
  def api_store_avatar_memory(payload: dict):
104
  return _handle(store_avatar_memory, payload)
@@ -129,3 +478,78 @@ def api_summarize_avatar(payload: dict):
129
  return _handle(summarize_avatar_context, payload)
130
 
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
  import os
3
  from datetime import datetime
4
  from pathlib import Path
5
+ from typing import Any, Dict, List, Optional
6
 
7
+ from fastapi import Body, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
8
  from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import Response
10
 
11
  from avatar_store import avatar_generated_path
12
  from generate_image_with_nano import build_prompt, run_edit
 
35
  allow_headers=["*"],
36
  )
37
 
38
+ JSONRPC_VERSION = "2.0"
39
+ MCP_PROTOCOL_VERSION = os.getenv("MCP_PROTOCOL_VERSION", "2025-06-18")
40
+ SERVER_INFO = {
41
+ "name": "avatar-mcp",
42
+ "version": os.getenv("AVATAR_MCP_VERSION", "0.1.0"),
43
+ }
44
+ SERVER_INSTRUCTIONS = (
45
+ "Use generate_as_avatar for dialog, retrieve_snippets/summarize_avatar for "
46
+ "context, and generate_image for Nano/Banana edits. Memory + portrait tools "
47
+ "manage persistent state under avatars/<id>."
48
+ )
49
+ SERVER_CAPABILITIES = {
50
+ "tools": {"listChanged": False},
51
+ }
52
+ JSONRPC_PARSE_ERROR = -32700
53
+ JSONRPC_INVALID_REQUEST = -32600
54
+ JSONRPC_METHOD_NOT_FOUND = -32601
55
+ JSONRPC_INVALID_PARAMS = -32602
56
+ JSONRPC_INTERNAL_ERROR = -32603
57
+
58
+
59
+ def _schema(properties: Dict[str, Dict[str, Any]], required: Optional[List[str]] = None):
60
+ schema = {"type": "object", "properties": properties}
61
+ if required:
62
+ schema["required"] = required
63
+ return schema
64
+
65
+
66
+ MCP_TOOLS_METADATA: List[Dict[str, Any]] = [
67
+ {
68
+ "name": "create_avatar",
69
+ "description": "Create a brand-new avatar persona using a short description.",
70
+ "inputSchema": _schema(
71
+ {
72
+ "description": {
73
+ "type": "string",
74
+ "description": "Persona description used to seed prompts.",
75
+ }
76
+ },
77
+ ["description"],
78
+ ),
79
+ },
80
+ {
81
+ "name": "get_avatar",
82
+ "description": "Fetch avatar metadata (public view unless a matching admin_id is supplied).",
83
+ "inputSchema": _schema(
84
+ {
85
+ "avatar_id": {
86
+ "type": "string",
87
+ "description": "Avatar identifier.",
88
+ },
89
+ "admin_id": {
90
+ "type": "string",
91
+ "description": "Admin credential to unlock private fields.",
92
+ },
93
+ },
94
+ ["avatar_id"],
95
+ ),
96
+ },
97
+ {
98
+ "name": "generate_as_avatar",
99
+ "description": "Generate a chat reply using the avatar's persona + history.",
100
+ "inputSchema": _schema(
101
+ {
102
+ "avatar_id": {"type": "string", "description": "Avatar identifier."},
103
+ "message": {"type": "string", "description": "User message text."},
104
+ "history": {
105
+ "type": "array",
106
+ "description": "Optional backscroll of prior dialog turns.",
107
+ "items": {"type": "object"},
108
+ },
109
+ },
110
+ ["avatar_id", "message"],
111
+ ),
112
+ },
113
+ {
114
+ "name": "generate_image",
115
+ "description": "Create a Nano/Banana visual using the current portrait plus message/reply context.",
116
+ "inputSchema": _schema(
117
+ {
118
+ "avatar_id": {"type": "string", "description": "Avatar identifier."},
119
+ "message": {
120
+ "type": "string",
121
+ "description": "User request driving the render.",
122
+ },
123
+ "reply": {
124
+ "type": "string",
125
+ "description": "Assistant reply text to seed the scene.",
126
+ },
127
+ },
128
+ ["avatar_id"],
129
+ ),
130
+ },
131
+ {
132
+ "name": "store_avatar_memory",
133
+ "description": "Append a memory/context entry for the avatar.",
134
+ "inputSchema": _schema(
135
+ {
136
+ "avatar_id": {"type": "string", "description": "Avatar identifier."},
137
+ "admin_id": {"type": "string", "description": "Admin credential."},
138
+ "entry": {"type": "string", "description": "Memory text to store."},
139
+ "private": {"type": "boolean", "description": "Hide memory from public calls."},
140
+ },
141
+ ["avatar_id", "admin_id", "entry"],
142
+ ),
143
+ },
144
+ {
145
+ "name": "delete_avatar_memory",
146
+ "description": "Remove a specific memory row by index.",
147
+ "inputSchema": _schema(
148
+ {
149
+ "avatar_id": {"type": "string", "description": "Avatar identifier."},
150
+ "admin_id": {"type": "string", "description": "Admin credential."},
151
+ "index": {"type": "integer", "description": "Zero-based memory index."},
152
+ },
153
+ ["avatar_id", "admin_id", "index"],
154
+ ),
155
+ },
156
+ {
157
+ "name": "get_avatar_context",
158
+ "description": "Retrieve persona, description, and memory (public or admin).",
159
+ "inputSchema": _schema(
160
+ {
161
+ "avatar_id": {"type": "string", "description": "Avatar identifier."},
162
+ "admin_id": {"type": "string", "description": "Admin credential."},
163
+ "mode": {
164
+ "type": "string",
165
+ "description": "Use 'admin' for private view; defaults to public.",
166
+ },
167
+ },
168
+ ["avatar_id"],
169
+ ),
170
+ },
171
+ {
172
+ "name": "delete_generated_images",
173
+ "description": "Remove on-disk generated image files for an avatar.",
174
+ "inputSchema": _schema(
175
+ {
176
+ "avatar_id": {"type": "string", "description": "Avatar identifier."},
177
+ "admin_id": {"type": "string", "description": "Admin credential."},
178
+ },
179
+ ["avatar_id", "admin_id"],
180
+ ),
181
+ },
182
+ {
183
+ "name": "retrieve_snippets",
184
+ "description": "Return top memories/persona snippets that match a lightweight query.",
185
+ "inputSchema": _schema(
186
+ {
187
+ "avatar_id": {"type": "string", "description": "Avatar identifier."},
188
+ "admin_id": {"type": "string", "description": "Admin credential (optional)."},
189
+ "query": {
190
+ "type": "string",
191
+ "description": "Free-form search text to score memories.",
192
+ },
193
+ "limit": {
194
+ "type": "integer",
195
+ "description": "Maximum snippet count to return.",
196
+ },
197
+ },
198
+ ["avatar_id"],
199
+ ),
200
+ },
201
+ {
202
+ "name": "summarize_avatar",
203
+ "description": "Summarize persona plus the most recent memories.",
204
+ "inputSchema": _schema(
205
+ {
206
+ "avatar_id": {"type": "string", "description": "Avatar identifier."},
207
+ "admin_id": {"type": "string", "description": "Admin credential (optional)."},
208
+ "max_mem": {
209
+ "type": "integer",
210
+ "description": "How many of the newest memories to include.",
211
+ },
212
+ },
213
+ ["avatar_id"],
214
+ ),
215
+ },
216
+ ]
217
+ MCP_TOOLS_BY_NAME = {tool["name"]: tool for tool in MCP_TOOLS_METADATA}
218
+
219
 
220
  def _handle(func, payload):
221
  try:
 
233
  return str(path)
234
 
235
 
236
+ def _jsonrpc_success_response(request_id: Any, result: Dict[str, Any] | None = None) -> Dict[str, Any]:
237
+ return {
238
+ "jsonrpc": JSONRPC_VERSION,
239
+ "id": request_id,
240
+ "result": result or {},
241
+ }
242
+
243
+
244
+ def _jsonrpc_error_response(
245
+ request_id: Any,
246
+ code: int,
247
+ message: str,
248
+ data: Dict[str, Any] | None = None,
249
+ ) -> Dict[str, Any]:
250
+ error = {"code": code, "message": message}
251
+ if data is not None:
252
+ error["data"] = data
253
+ return {
254
+ "jsonrpc": JSONRPC_VERSION,
255
+ "id": request_id,
256
+ "error": error,
257
+ }
258
+
259
+
260
+ def _tool_call_payload(data: Any, is_error: bool = False) -> Dict[str, Any]:
261
+ if isinstance(data, dict):
262
+ content_text = json.dumps(data, ensure_ascii=False, indent=2)
263
+ structured = data
264
+ elif isinstance(data, (list, tuple)):
265
+ structured = {"data": data}
266
+ content_text = json.dumps(data, ensure_ascii=False, indent=2)
267
+ else:
268
+ structured = None
269
+ content_text = str(data)
270
+ if not content_text:
271
+ content_text = "ok" if not is_error else "error"
272
+ payload: Dict[str, Any] = {
273
+ "content": [
274
+ {
275
+ "type": "text",
276
+ "text": content_text,
277
+ }
278
+ ]
279
+ }
280
+ if structured is not None:
281
+ payload["structuredContent"] = structured
282
+ if is_error:
283
+ payload["isError"] = True
284
+ return payload
285
+
286
+
287
  @fastapi_app.post("/mcp/create_avatar")
288
  def api_create_avatar(payload: dict):
289
  return _handle(create_avatar, payload)
 
299
  return _handle(generate_as_avatar, payload)
300
 
301
 
302
+ def _generate_image(payload: dict):
 
303
  payload = payload or {}
304
  avatar_id = payload.get("avatar_id")
305
  if not avatar_id:
306
+ raise ValueError("avatar_id required")
307
  message = (payload.get("message") or "").strip()
308
  reply = (payload.get("reply") or "").strip()
309
  context = message or reply or "scene with the avatar"
310
  try:
311
  avatar = ensure_public_avatar(avatar_id)
312
  except ValueError as exc:
313
+ raise ValueError(str(exc))
314
  portrait_path = _resolve_path(avatar.get("portrait"))
315
  if not portrait_path or not Path(portrait_path).exists():
316
+ raise ValueError("portrait not found for avatar")
317
  prompt = build_prompt(avatar.get("persona", "Avatar"), context)
318
  timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
319
  out_path = avatar_generated_path(avatar_id, timestamp)
 
323
  except SystemExit:
324
  rc = 1
325
  if rc != 0:
326
+ raise RuntimeError("image generation failed")
327
  record_generated_image(avatar_id, out_path, prompt, timestamp)
328
  return {
329
  "status": "generated",
 
333
  }
334
 
335
 
336
+ @fastapi_app.post("/mcp/generate_image")
337
+ def api_generate_image(payload: dict):
338
+ try:
339
+ return _generate_image(payload)
340
+ except ValueError as exc:
341
+ raise HTTPException(status_code=400, detail=str(exc))
342
+ except RuntimeError as exc:
343
+ raise HTTPException(status_code=500, detail=str(exc))
344
+
345
+
346
+ def _handle_jsonrpc_tool_call(params: Dict[str, Any] | None, request_id: Any):
347
+ params = params or {}
348
+ name = params.get("name")
349
+ if not name or not isinstance(name, str):
350
+ return _jsonrpc_error_response(request_id, JSONRPC_INVALID_PARAMS, "tool name required")
351
+ arguments = params.get("arguments") or {}
352
+ if not isinstance(arguments, dict):
353
+ return _jsonrpc_error_response(
354
+ request_id,
355
+ JSONRPC_INVALID_PARAMS,
356
+ "tool arguments must be an object",
357
+ )
358
+ handler = WEBSOCKET_TOOLS.get(name)
359
+ if not handler:
360
+ return _jsonrpc_error_response(request_id, JSONRPC_METHOD_NOT_FOUND, f"unknown tool: {name}")
361
+ try:
362
+ result = handler(arguments)
363
+ return _jsonrpc_success_response(request_id, _tool_call_payload(result))
364
+ except (ValueError, RuntimeError) as exc:
365
+ return _jsonrpc_success_response(
366
+ request_id,
367
+ _tool_call_payload({"error": str(exc)}, is_error=True),
368
+ )
369
+ except Exception as exc:
370
+ return _jsonrpc_error_response(
371
+ request_id,
372
+ JSONRPC_INTERNAL_ERROR,
373
+ "internal error during tool call",
374
+ {"details": str(exc), "tool": name},
375
+ )
376
+
377
+
378
+ def _handle_single_jsonrpc_request(payload: Any):
379
+ if not isinstance(payload, dict):
380
+ return _jsonrpc_error_response(None, JSONRPC_INVALID_REQUEST, "request must be an object")
381
+ request_id = payload.get("id")
382
+ method = payload.get("method")
383
+ if not method:
384
+ return _jsonrpc_error_response(request_id, JSONRPC_INVALID_REQUEST, "method is required")
385
+ params = payload.get("params")
386
+ if params is not None and not isinstance(params, dict):
387
+ # Notifications may omit params entirely; when supplied, enforce object
388
+ return _jsonrpc_error_response(request_id, JSONRPC_INVALID_PARAMS, "params must be an object")
389
+
390
+ if method == "initialize":
391
+ return _jsonrpc_success_response(
392
+ request_id,
393
+ {
394
+ "protocolVersion": MCP_PROTOCOL_VERSION,
395
+ "capabilities": SERVER_CAPABILITIES,
396
+ "serverInfo": SERVER_INFO,
397
+ "instructions": SERVER_INSTRUCTIONS,
398
+ },
399
+ )
400
+ if method == "notifications/initialized":
401
+ return None
402
+ if method == "notifications/cancelled":
403
+ return None
404
+ if method == "ping":
405
+ return _jsonrpc_success_response(request_id, {})
406
+ if method == "tools/list":
407
+ result: Dict[str, Any] = {"tools": MCP_TOOLS_METADATA}
408
+ if params and "cursor" in params and params["cursor"]:
409
+ result["nextCursor"] = None
410
+ return _jsonrpc_success_response(request_id, result)
411
+ if method == "tools/call":
412
+ return _handle_jsonrpc_tool_call(params, request_id)
413
+ if method == "resources/list":
414
+ return _jsonrpc_success_response(request_id, {"resources": []})
415
+ if method == "resources/templates/list":
416
+ return _jsonrpc_success_response(request_id, {"resourceTemplates": []})
417
+ if method == "resources/read":
418
+ return _jsonrpc_error_response(
419
+ request_id,
420
+ JSONRPC_METHOD_NOT_FOUND,
421
+ "resources/read not supported",
422
+ )
423
+ if method == "prompts/list":
424
+ return _jsonrpc_success_response(request_id, {"prompts": []})
425
+ if method == "prompts/get":
426
+ return _jsonrpc_error_response(
427
+ request_id,
428
+ JSONRPC_METHOD_NOT_FOUND,
429
+ "prompts/get not supported",
430
+ )
431
+ return _jsonrpc_error_response(request_id, JSONRPC_METHOD_NOT_FOUND, f"unknown method: {method}")
432
+
433
+
434
+ def _handle_jsonrpc_payload(payload: Any):
435
+ if isinstance(payload, list):
436
+ responses = []
437
+ for entry in payload:
438
+ resp = _handle_single_jsonrpc_request(entry)
439
+ if resp is not None:
440
+ responses.append(resp)
441
+ if not responses:
442
+ # Pure notification batches must not emit a body; return HTTP 204 to signal no content.
443
+ return Response(status_code=204)
444
+ return responses
445
+ response = _handle_single_jsonrpc_request(payload)
446
+ if response is None:
447
+ return Response(status_code=204)
448
+ return response
449
+
450
+
451
  @fastapi_app.post("/mcp/store_avatar_memory")
452
  def api_store_avatar_memory(payload: dict):
453
  return _handle(store_avatar_memory, payload)
 
478
  return _handle(summarize_avatar_context, payload)
479
 
480
 
481
+ WEBSOCKET_TOOLS = {} # filled later after helper definitions
482
+
483
+
484
+ @fastapi_app.websocket("/mcp/ws")
485
+ async def websocket_mcp(websocket: WebSocket):
486
+ await websocket.accept()
487
+ await websocket.send_json({"type": "welcome", "tools": list(WEBSOCKET_TOOLS.keys())})
488
+ while True:
489
+ try:
490
+ data = await websocket.receive_json()
491
+ except WebSocketDisconnect:
492
+ break
493
+ except Exception:
494
+ await websocket.close(code=1003)
495
+ break
496
+ req_id = data.get("id")
497
+ tool = data.get("tool")
498
+ payload = data.get("payload") or {}
499
+ handler = WEBSOCKET_TOOLS.get(tool)
500
+ if not handler:
501
+ await websocket.send_json(
502
+ {"id": req_id, "ok": False, "error": f"unknown tool: {tool}"}
503
+ )
504
+ continue
505
+ try:
506
+ result = handler(payload)
507
+ await websocket.send_json({"id": req_id, "ok": True, "result": result})
508
+ except Exception as exc:
509
+ await websocket.send_json({"id": req_id, "ok": False, "error": str(exc)})
510
+
511
+
512
+ @fastapi_app.get("/mcp/http")
513
+ def http_list_tools():
514
+ return {"tools": list(WEBSOCKET_TOOLS.keys())}
515
+
516
+
517
+ @fastapi_app.post("/mcp/http")
518
+ def http_invoke_tool(payload: Any = Body(...)):
519
+ if isinstance(payload, list):
520
+ return _handle_jsonrpc_payload(payload)
521
+ if isinstance(payload, dict):
522
+ if "jsonrpc" in payload or "method" in payload:
523
+ return _handle_jsonrpc_payload(payload)
524
+ payload = payload or {}
525
+ tool_name = payload.get("tool")
526
+ handler = WEBSOCKET_TOOLS.get(tool_name)
527
+ if not handler:
528
+ raise HTTPException(status_code=404, detail="unknown tool")
529
+ tool_payload = payload.get("payload") or {}
530
+ try:
531
+ result = handler(tool_payload)
532
+ return {"ok": True, "result": result}
533
+ except ValueError as exc:
534
+ raise HTTPException(status_code=400, detail=str(exc))
535
+ except RuntimeError as exc:
536
+ raise HTTPException(status_code=500, detail=str(exc))
537
+ except Exception as exc:
538
+ raise HTTPException(status_code=500, detail=str(exc))
539
+ raise HTTPException(status_code=400, detail="invalid payload")
540
+
541
+
542
+ WEBSOCKET_TOOLS.update(
543
+ {
544
+ "create_avatar": create_avatar,
545
+ "get_avatar": get_avatar,
546
+ "generate_as_avatar": generate_as_avatar,
547
+ "generate_image": _generate_image,
548
+ "store_avatar_memory": store_avatar_memory,
549
+ "delete_avatar_memory": delete_avatar_memory,
550
+ "get_avatar_context": get_avatar_context,
551
+ "delete_generated_images": delete_generated_images,
552
+ "retrieve_snippets": retrieve_avatar_snippets,
553
+ "summarize_avatar": summarize_avatar_context,
554
+ }
555
+ )
app.py CHANGED
@@ -318,7 +318,7 @@ def maybe_generate_image(avatar_id, history, generate_image=True, current_image=
318
  if rc == 0:
319
  record_generated_image(avatar_id, out_path, prompt, timestamp)
320
  return str(out_path), True
321
- except Exception:
322
  return current_image, generate_image
323
 
324
  return current_image, generate_image
 
318
  if rc == 0:
319
  record_generated_image(avatar_id, out_path, prompt, timestamp)
320
  return str(out_path), True
321
+ except BaseException:
322
  return current_image, generate_image
323
 
324
  return current_image, generate_image
requirements.txt CHANGED
@@ -6,3 +6,4 @@ google-genai
6
  Pillow
7
  spaces
8
  pydantic>=2,<3
 
 
6
  Pillow
7
  spaces
8
  pydantic>=2,<3
9
+ websockets
run.sh CHANGED
@@ -6,7 +6,7 @@ docker rm -f avatar-mcp-container 2>/dev/null || true
6
  echo "🚀 Starting new container..."
7
  docker run -d \
8
  --name avatar-mcp-container \
9
- -p 7860:7860 \
10
  -v "$(pwd)":/app \
11
  -e OPENAI_API_KEY="$OPENAI_API_KEY" \
12
  -e GOOGLE_API_KEY="$GOOGLE_API_KEY" \
 
6
  echo "🚀 Starting new container..."
7
  docker run -d \
8
  --name avatar-mcp-container \
9
+ -p 7866:7860 \
10
  -v "$(pwd)":/app \
11
  -e OPENAI_API_KEY="$OPENAI_API_KEY" \
12
  -e GOOGLE_API_KEY="$GOOGLE_API_KEY" \
test_mcp_http.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ import httpx
4
+
5
+ HTTP_ENDPOINT = "http://localhost:7866/mcp/http"
6
+ DEFAULT_AVATAR = "08a2fb96"
7
+
8
+
9
+ def main():
10
+ with httpx.Client(timeout=15) as client:
11
+ info = client.get(HTTP_ENDPOINT)
12
+ info.raise_for_status()
13
+ data = info.json()
14
+ tools = data.get("tools") or []
15
+ print("Available tools:")
16
+ for tool in tools:
17
+ print(f"- {tool}")
18
+ sample = {
19
+ "tool": "get_avatar",
20
+ "payload": {"avatar_id": DEFAULT_AVATAR},
21
+ }
22
+ resp = client.post(HTTP_ENDPOINT, json=sample)
23
+ resp.raise_for_status()
24
+ print("\nSample response:")
25
+ print(json.dumps(resp.json(), indent=2))
26
+
27
+
28
+ if __name__ == "__main__":
29
+ main()
test_mcp_ws.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+
4
+ import websockets
5
+
6
+ MCP_WS = "ws://localhost:7866/mcp/ws"
7
+
8
+
9
+ async def main():
10
+ async with websockets.connect(MCP_WS) as ws:
11
+ welcome_raw = await ws.recv()
12
+ welcome = json.loads(welcome_raw)
13
+ tools = welcome.get("tools") or []
14
+ print("Welcome payload:", json.dumps(welcome, indent=2))
15
+ print("Available tools:")
16
+ for tool in tools:
17
+ print(f"- {tool}")
18
+
19
+
20
+ if __name__ == "__main__":
21
+ asyncio.run(main())