improved MCP tooling with schemas
Browse files- .vscode/mcp.json +9 -0
- .vscode/settings.json +10 -0
- README.md +4 -0
- api.py +431 -7
- app.py +1 -1
- requirements.txt +1 -0
- run.sh +1 -1
- test_mcp_http.py +29 -0
- 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 |
-
|
| 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
|
| 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
|
| 80 |
portrait_path = _resolve_path(avatar.get("portrait"))
|
| 81 |
if not portrait_path or not Path(portrait_path).exists():
|
| 82 |
-
raise
|
| 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
|
| 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
|
| 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
|
| 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())
|