Skip to content

Commit eae64a1

Browse files
committed
test(mcp_server): cover _get_prompt nameless-arg crash + required-arg enforcement
Three direct unit tests using a mocked _fetch_manifest (no subprocess/hub needed): - nameless required arg no longer raises KeyError (regression) - named required arg blocks the call when absent from arguments - named required arg passes through when provided + substitution verified Mutation-verified: restoring arg["name"] causes the regression test to crash.
1 parent d71e799 commit eae64a1

1 file changed

Lines changed: 83 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Unit coverage for ZhubMCPServer._get_prompt edge cases.
2+
3+
Tests the required-arg check path with a mocked _fetch_manifest so there is
4+
no need for a running hub or subprocess. The specific regression tested here:
5+
_get_prompt previously accessed arg["name"] with bracket notation after safely
6+
calling arg.get("required"). A publisher that sent a prompt argument object
7+
missing the "name" field (malformed manifest) caused an uncaught KeyError
8+
instead of returning a clean error tuple.
9+
"""
10+
11+
import asyncio
12+
from unittest.mock import AsyncMock, patch
13+
14+
import pytest
15+
16+
from zhub.mcp_server import ZhubMCPServer
17+
18+
19+
def _make_server() -> ZhubMCPServer:
20+
s = ZhubMCPServer(hub="http://fake", ai="fake", key="zk_fake")
21+
s._http = object() # prevent start() needing a real httpx client
22+
return s
23+
24+
25+
def _manifest_with_prompts(prompts: list) -> dict:
26+
return {"name": "fake", "prompts": prompts}
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_get_prompt_nameless_required_arg_does_not_crash():
31+
"""A required arg with no 'name' field must not raise KeyError."""
32+
server = _make_server()
33+
manifest = _manifest_with_prompts([
34+
{
35+
"name": "greet",
36+
"arguments": [{"required": True}], # <-- no "name" key
37+
"messages": [{"role": "user", "content": "Hello!"}],
38+
},
39+
])
40+
with patch.object(server, "_fetch_manifest", new=AsyncMock(return_value=manifest)):
41+
result, err = await server._get_prompt("greet", {})
42+
# nameless required arg is unenforced (can't be checked) — returns the rendered prompt
43+
assert err is None
44+
assert result is not None
45+
assert result["messages"][0]["role"] == "user"
46+
47+
48+
@pytest.mark.asyncio
49+
async def test_get_prompt_named_required_arg_blocks_when_absent():
50+
"""A required arg with a name still blocks if missing from the call."""
51+
server = _make_server()
52+
manifest = _manifest_with_prompts([
53+
{
54+
"name": "summarize",
55+
"arguments": [{"name": "text", "required": True}],
56+
"messages": [{"role": "user", "content": "Summarize: {text}"}],
57+
},
58+
])
59+
with patch.object(server, "_fetch_manifest", new=AsyncMock(return_value=manifest)):
60+
result, err = await server._get_prompt("summarize", {})
61+
assert result is None
62+
assert err is not None
63+
assert "text" in err
64+
65+
66+
@pytest.mark.asyncio
67+
async def test_get_prompt_named_required_arg_passes_when_provided():
68+
"""A required arg with a name passes when the arg is provided."""
69+
server = _make_server()
70+
manifest = _manifest_with_prompts([
71+
{
72+
"name": "summarize",
73+
"arguments": [{"name": "text", "required": True}],
74+
"messages": [{"role": "user", "content": "Summarize: {text}"}],
75+
},
76+
])
77+
with patch.object(server, "_fetch_manifest", new=AsyncMock(return_value=manifest)):
78+
result, err = await server._get_prompt("summarize", {"text": "hello world"})
79+
assert err is None
80+
assert result is not None
81+
content = result["messages"][0]["content"]
82+
text = content if isinstance(content, str) else content.get("text", "")
83+
assert "hello world" in text

0 commit comments

Comments
 (0)