mirror of
https://github.com/debpalash/VoiceStudio.git
synced 2026-10-02 09:34:38 +08:00
fix(mcp): serialize structured responses as JSON
This commit is contained in:
@@ -598,7 +598,7 @@ def create_mcp_server(app=None):
|
||||
and personality.
|
||||
"""
|
||||
profiles = await _api_get("/profiles")
|
||||
return str(profiles)
|
||||
return json.dumps(profiles)
|
||||
|
||||
@mcp.tool()
|
||||
async def list_personalities() -> str:
|
||||
@@ -608,7 +608,7 @@ def create_mcp_server(app=None):
|
||||
instruct text. Use the instruct text with generate_speech.
|
||||
"""
|
||||
presets = await _api_get("/personalities")
|
||||
return str(presets)
|
||||
return json.dumps(presets)
|
||||
|
||||
@mcp.tool()
|
||||
async def list_languages() -> str:
|
||||
@@ -660,13 +660,13 @@ def create_mcp_server(app=None):
|
||||
"application/octet-stream")},
|
||||
timeout=_post_timeout_s("transcribe"),
|
||||
)
|
||||
return str(r.json())
|
||||
return json.dumps(r.json())
|
||||
|
||||
@mcp.tool()
|
||||
async def check_health() -> str:
|
||||
"""Check if the VoiceStudio backend is running and what GPU device is active."""
|
||||
info = await _api_get("/health")
|
||||
return str(info)
|
||||
return json.dumps(info)
|
||||
|
||||
# ── Resources ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -676,14 +676,14 @@ def create_mcp_server(app=None):
|
||||
profiles = await _api_get("/profiles")
|
||||
for p in profiles:
|
||||
if p.get("id") == profile_id:
|
||||
return str(p)
|
||||
return f'{{"error":"Voice profile {profile_id} not found"}}'
|
||||
return json.dumps(p)
|
||||
return json.dumps({"error": f"Voice profile {profile_id} not found"})
|
||||
|
||||
@mcp.resource("history://recent")
|
||||
async def get_recent_history() -> str:
|
||||
"""Get the 20 most recent generation history items."""
|
||||
history = await _api_get("/history")
|
||||
return str(history[:20])
|
||||
return json.dumps(history[:20])
|
||||
|
||||
@mcp.tool()
|
||||
async def clone_voice(
|
||||
|
||||
@@ -18,6 +18,13 @@ start once VoiceStudio is open.
|
||||
| `list_voices` / `list_personalities` / `list_languages` | enumerate what's available. |
|
||||
| `check_health` | backend status + active GPU device. |
|
||||
|
||||
`list_voices`, `list_personalities`, `transcribe`, and `check_health` return
|
||||
JSON-encoded text in their MCP tool responses. The `voice://{profile_id}`
|
||||
and `history://recent` resources also return JSON text; recent history
|
||||
contains at most 20 items. Parse the returned text with a JSON parser
|
||||
(for example, Python's `json.loads`). A missing voice returns a JSON object
|
||||
with an `error` message.
|
||||
|
||||
## Output mode and file inputs
|
||||
|
||||
An LLM agent pays for every byte it receives in context, and a WAV as base64
|
||||
|
||||
@@ -5,6 +5,8 @@ torch — these run locally). The mount-on-main check imports `main` and is
|
||||
validated in CI (local torch/Triton segfault on main-importing tests).
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
os.environ.setdefault("OMNIVOICE_MODEL", "test")
|
||||
@@ -25,6 +27,135 @@ def test_server_builds_with_expected_tools():
|
||||
"list_languages", "check_health"} <= names
|
||||
|
||||
|
||||
def test_list_voices_returns_valid_json(monkeypatch):
|
||||
"""Guard the tool's promised JSON format against Python repr output.
|
||||
|
||||
Return a nonempty profile list from a stub API, then parse the real MCP
|
||||
reply and compare all values, including booleans, null and nested data.
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from mcp_server import create_mcp_server
|
||||
|
||||
profiles = [{
|
||||
"id": "voice-1",
|
||||
"name": "Voice \u2713",
|
||||
"kind": "clone",
|
||||
"personality": None,
|
||||
"ready": True,
|
||||
"enabled": False,
|
||||
"metadata": {"count": 1},
|
||||
}]
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/profiles")
|
||||
async def list_profiles():
|
||||
return profiles
|
||||
|
||||
monkeypatch.delenv("OMNIVOICE_API_URL", raising=False)
|
||||
server = create_mcp_server(app=app)
|
||||
result = asyncio.run(server.call_tool("list_voices", {}))
|
||||
content = result[0] if isinstance(result, tuple) else result
|
||||
assert json.loads(content[0].text) == profiles
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def json_response_server(monkeypatch):
|
||||
"""Build real MCP servers backed by a configurable in-process stub API.
|
||||
|
||||
This isolates serialization tests from network calls and model inference.
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from mcp_server import create_mcp_server
|
||||
|
||||
def build(path, payload, method="GET"):
|
||||
app = FastAPI()
|
||||
|
||||
@app.api_route(path, methods=[method])
|
||||
async def response():
|
||||
return payload
|
||||
|
||||
monkeypatch.delenv("OMNIVOICE_API_URL", raising=False)
|
||||
return create_mcp_server(app=app)
|
||||
|
||||
return build
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool,path,payload", [
|
||||
("list_personalities", "/personalities", [
|
||||
{"name": "Narrator", "instruct": "Read calmly", "enabled": True},
|
||||
]),
|
||||
("check_health", "/health", {
|
||||
"ready": True, "enabled": False, "device": None,
|
||||
}),
|
||||
])
|
||||
def test_metadata_tools_return_valid_json(json_response_server, tool, path, payload):
|
||||
"""Keep personality and health responses parseable by JSON clients.
|
||||
|
||||
Feed each tool a stub API response and verify its JSON text decodes to
|
||||
the original list or dictionary without changing booleans or nulls.
|
||||
"""
|
||||
server = json_response_server(path, payload)
|
||||
result = asyncio.run(server.call_tool(tool, {}))
|
||||
content = result[0] if isinstance(result, tuple) else result
|
||||
assert json.loads(content[0].text) == payload
|
||||
|
||||
|
||||
def test_transcribe_returns_valid_json(json_response_server):
|
||||
"""Preserve transcript content when serializing the tool's JSON reply.
|
||||
|
||||
Stub the upload endpoint, call the real tool with placeholder audio,
|
||||
and round-trip quotes, a newline, Unicode and metadata without ASR.
|
||||
"""
|
||||
payload = {"text": "She said, \"hello\".\nVoice \u2713", "language": None,
|
||||
"duration": 1.25}
|
||||
server = json_response_server("/transcribe", payload, method="POST")
|
||||
# The stub accepts the upload; no ASR engine reads these placeholder bytes.
|
||||
audio = base64.b64encode(b"RIFFxxxxWAVE").decode("ascii")
|
||||
result = asyncio.run(server.call_tool("transcribe", {"audio_base64": audio}))
|
||||
content = result[0] if isinstance(result, tuple) else result
|
||||
assert json.loads(content[0].text) == payload
|
||||
|
||||
|
||||
def test_voice_resource_returns_valid_json(json_response_server):
|
||||
"""Ensure clients can decode the requested voice's resource as JSON.
|
||||
|
||||
Supply two profiles and read one by URI, checking that selection and
|
||||
serialization preserve its Unicode, null and nested metadata.
|
||||
"""
|
||||
profile = {"id": "voice-1", "name": "Voice \u2713", "personality": None,
|
||||
"metadata": {"ready": True}}
|
||||
server = json_response_server("/profiles", [{"id": "other"}, profile])
|
||||
contents = list(asyncio.run(server.read_resource("voice://voice-1")))
|
||||
assert json.loads(contents[0].content) == profile
|
||||
|
||||
|
||||
def test_recent_history_returns_valid_json(json_response_server):
|
||||
"""Keep history parseable while preserving its existing 20-item limit.
|
||||
|
||||
Supply 25 entries through the stub API and verify the resource decodes
|
||||
to exactly the first 20 entries in the same order.
|
||||
"""
|
||||
history = [{"id": str(i), "text": "Voice \u2713", "profile_id": None}
|
||||
for i in range(25)]
|
||||
server = json_response_server("/history", history)
|
||||
contents = list(asyncio.run(server.read_resource("history://recent")))
|
||||
assert json.loads(contents[0].content) == history[:20]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("profile_id", ["missing", 'missing"quote'])
|
||||
def test_missing_voice_resource_returns_valid_json(json_response_server, profile_id):
|
||||
"""Prevent quoted voice IDs from breaking the JSON error response.
|
||||
|
||||
Return no profiles, then read ordinary and quoted missing IDs and
|
||||
verify each error parses and preserves the requested ID in its message.
|
||||
"""
|
||||
server = json_response_server("/profiles", [])
|
||||
contents = list(asyncio.run(server.read_resource(f"voice://{profile_id}")))
|
||||
assert json.loads(contents[0].content) == {
|
||||
"error": f"Voice profile {profile_id} not found",
|
||||
}
|
||||
|
||||
|
||||
def test_streamable_app_serves_at_root_for_submounting():
|
||||
from mcp_server import create_mcp_server
|
||||
|
||||
|
||||
Reference in New Issue
Block a user