fix(mcp): serialize structured responses as JSON

This commit is contained in:
abdullah-rashid62
2026-09-30 12:22:09 +05:00
parent 0834c8be28
commit a83c91ff00
3 changed files with 145 additions and 7 deletions
+7 -7
View File
@@ -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(
+7
View File
@@ -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
+131
View File
@@ -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