Skip to content

Commit 8ce9a4e

Browse files
Zawwarsami16claude
andcommitted
phase 9.0: MCP resources + prompts surface
publishers can now declare resources and prompts inline alongside chat capabilities; the MCP server bridge (python -m zhub.mcp_server) surfaces all three MCP primitive surfaces (tools / resources / prompts) to Claude Desktop / Cursor / Cline / any MCP host. new manifest fields: resources: list of {uri, name, description?, mimeType?, content} prompts: list of {name, description?, arguments?: [{name, required?, description?}], messages: [{role, content (str)}] with {var} placeholders} publish() gains resources=[...] and prompts=[...] keyword args. both default to empty list (zero migration cost for existing publishers). mcp_server changes: initialize handshake now advertises capabilities.{tools, resources, prompts} so MCP hosts know to call all three */list methods. resources/list, resources/read, prompts/list, prompts/get all fetch the manifest on demand and serve from the inline declarations. prompts/get does simple {var} substitution against the supplied arguments dict; missing required args return JSON-RPC error -32602 (invalid params); unknown prompt name returns -32601. v1 is static-only — content / messages are declared at publish time. dynamic resources (publisher round-trip per read) and prompt argument schema validation are explicit out-of-scope items in the spec, slated for 9.x if/when needed. tests: initialize advertises all three surfaces; resources list + read with mimeType passthrough + unknown-uri errors; prompts list with arg metadata + get with substitution + missing required arg error + unknown prompt error. 150/150 pytest now. spec at docs/superpowers/specs/2026-05-10-zhub-phase-9.0-mcp-resources-prompts-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 590d036 commit 8ce9a4e

6 files changed

Lines changed: 400 additions & 10 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ CI runs the Python suite on 3.10 / 3.11 / 3.12 plus the JS module test on every
353353
| **~~4.2b~~**| True chunked tool_call delta streaming through SSE (default mode passes deltas through; `auto` mode also resolves+continues) |
354354
| **7.1** | Per-exposure access policies (whitelist of AI names / publisher keys) |
355355
| **More brains** | Cohere, Mistral, Together, Bedrock, Vertex, vLLM-direct |
356-
| **MCP resources + prompts** | Surface zhub-served files & prompts to MCP hosts, beyond just tools |
356+
| ~~**MCP resources + prompts**~~| Phase 9.0: publishers declare `resources=` and `prompts=` in `publish()`; the MCP bridge surfaces them as resources/list, resources/read, prompts/list, prompts/get |
357357
| ~~**Hub UI dashboard**~~| Live view of connected publishers, recent requests, latency, exposed devices — at `/` (Phase 8.0) |
358358
| **Multi-tier API keys** | Read / full / admin tiers per AI |
359359
| **Federation v2** | Signed peer relationships, shared identity registry across federated hubs |
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""Phase 9.0 — MCP server bridge serves the full triple: tools + resources + prompts.
2+
3+
Reuses the same subprocess-driven JSON-RPC test pattern as test_mcp_server.py.
4+
"""
5+
6+
import asyncio
7+
import json
8+
import os
9+
import socket
10+
import sys
11+
import threading
12+
import time
13+
14+
import pytest
15+
16+
try:
17+
import fastapi # noqa
18+
import uvicorn # noqa
19+
DEPS_AVAILABLE = True
20+
except ImportError:
21+
DEPS_AVAILABLE = False
22+
23+
if DEPS_AVAILABLE:
24+
from zhub.server import create_app
25+
from zhub import publish
26+
27+
28+
def _free_port() -> int:
29+
with socket.socket() as s:
30+
s.bind(("", 0))
31+
return s.getsockname()[1]
32+
33+
34+
@pytest.fixture(scope="module")
35+
def hub():
36+
if not DEPS_AVAILABLE:
37+
pytest.skip("fastapi/uvicorn not installed")
38+
port = _free_port()
39+
app = create_app()
40+
41+
def run():
42+
config = uvicorn.Config(app, host="127.0.0.1", port=port,
43+
log_level="warning")
44+
asyncio.run(uvicorn.Server(config).serve())
45+
46+
threading.Thread(target=run, daemon=True).start()
47+
for _ in range(30):
48+
try:
49+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
50+
break
51+
except OSError:
52+
time.sleep(0.1)
53+
yield port
54+
55+
56+
async def _send_recv(proc, method: str, params: dict, req_id: int) -> dict:
57+
msg = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params}
58+
proc.stdin.write((json.dumps(msg) + "\n").encode())
59+
await proc.stdin.drain()
60+
while True:
61+
line = await asyncio.wait_for(proc.stdout.readline(), timeout=10.0)
62+
if not line:
63+
raise AssertionError("mcp_server closed stdout")
64+
try:
65+
data = json.loads(line.decode())
66+
except json.JSONDecodeError:
67+
continue
68+
if data.get("id") == req_id:
69+
return data
70+
71+
72+
async def _spawn_mcp(hub_http: str, ai: str, key: str):
73+
env = dict(os.environ)
74+
env["PYTHONUNBUFFERED"] = "1"
75+
return await asyncio.create_subprocess_exec(
76+
sys.executable, "-m", "zhub.mcp_server",
77+
"--hub", hub_http, "--ai", ai, "--key", key,
78+
stdin=asyncio.subprocess.PIPE,
79+
stdout=asyncio.subprocess.PIPE,
80+
stderr=asyncio.subprocess.PIPE,
81+
env=env,
82+
)
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_initialize_advertises_all_three_surfaces(hub):
87+
hub_http = f"http://127.0.0.1:{hub}"
88+
pub = publish(name="mcp-init-bot", description="x",
89+
chat_handler=lambda m, o: "ok",
90+
hub_url=f"ws://127.0.0.1:{hub}")
91+
for _ in range(50):
92+
if pub.api_key:
93+
break
94+
await asyncio.sleep(0.1)
95+
96+
proc = await _spawn_mcp(hub_http, pub.name, pub.api_key)
97+
try:
98+
r = await _send_recv(proc, "initialize", {
99+
"protocolVersion": "2024-11-05", "capabilities": {},
100+
"clientInfo": {"name": "pytest", "version": "0"},
101+
}, 1)
102+
caps = r["result"]["capabilities"]
103+
assert "tools" in caps
104+
assert "resources" in caps
105+
assert "prompts" in caps
106+
finally:
107+
proc.terminate()
108+
try:
109+
await asyncio.wait_for(proc.wait(), timeout=3.0)
110+
except asyncio.TimeoutError:
111+
proc.kill()
112+
await proc.wait()
113+
114+
115+
@pytest.mark.asyncio
116+
async def test_resources_list_and_read(hub):
117+
hub_http = f"http://127.0.0.1:{hub}"
118+
resources = [
119+
{
120+
"uri": "zhub://res-bot/readme",
121+
"name": "readme",
122+
"description": "the project readme",
123+
"mimeType": "text/markdown",
124+
"content": "# Project\n\nHello.",
125+
},
126+
{
127+
"uri": "zhub://res-bot/config",
128+
"name": "config",
129+
"mimeType": "application/json",
130+
"content": "{\"k\":1}",
131+
},
132+
]
133+
pub = publish(name="res-bot", description="r",
134+
chat_handler=lambda m, o: "ok",
135+
hub_url=f"ws://127.0.0.1:{hub}",
136+
resources=resources)
137+
for _ in range(50):
138+
if pub.api_key:
139+
break
140+
await asyncio.sleep(0.1)
141+
142+
proc = await _spawn_mcp(hub_http, pub.name, pub.api_key)
143+
try:
144+
await _send_recv(proc, "initialize", {
145+
"protocolVersion": "2024-11-05", "capabilities": {},
146+
"clientInfo": {"name": "pytest", "version": "0"},
147+
}, 1)
148+
listed = await _send_recv(proc, "resources/list", {}, 2)
149+
items = listed["result"]["resources"]
150+
assert {x["uri"] for x in items} == {
151+
"zhub://res-bot/readme", "zhub://res-bot/config",
152+
}
153+
# Each must have name, no content (read separately)
154+
names = {x["name"] for x in items}
155+
assert names == {"readme", "config"}
156+
157+
read = await _send_recv(proc, "resources/read",
158+
{"uri": "zhub://res-bot/readme"}, 3)
159+
contents = read["result"]["contents"]
160+
assert contents[0]["uri"] == "zhub://res-bot/readme"
161+
assert contents[0]["text"].startswith("# Project")
162+
assert contents[0]["mimeType"] == "text/markdown"
163+
164+
bad = await _send_recv(proc, "resources/read",
165+
{"uri": "zhub://nope"}, 4)
166+
assert "error" in bad
167+
finally:
168+
proc.terminate()
169+
try:
170+
await asyncio.wait_for(proc.wait(), timeout=3.0)
171+
except asyncio.TimeoutError:
172+
proc.kill()
173+
await proc.wait()
174+
175+
176+
@pytest.mark.asyncio
177+
async def test_prompts_list_and_get_with_substitution(hub):
178+
hub_http = f"http://127.0.0.1:{hub}"
179+
prompts = [
180+
{
181+
"name": "summarize",
182+
"description": "summarize text in 3 bullets",
183+
"arguments": [
184+
{"name": "text", "required": True,
185+
"description": "the text to summarize"},
186+
],
187+
"messages": [
188+
{"role": "user",
189+
"content": "Summarize this in 3 bullets:\n\n{text}"},
190+
],
191+
},
192+
]
193+
pub = publish(name="prompt-bot", description="p",
194+
chat_handler=lambda m, o: "ok",
195+
hub_url=f"ws://127.0.0.1:{hub}",
196+
prompts=prompts)
197+
for _ in range(50):
198+
if pub.api_key:
199+
break
200+
await asyncio.sleep(0.1)
201+
202+
proc = await _spawn_mcp(hub_http, pub.name, pub.api_key)
203+
try:
204+
await _send_recv(proc, "initialize", {
205+
"protocolVersion": "2024-11-05", "capabilities": {},
206+
"clientInfo": {"name": "pytest", "version": "0"},
207+
}, 1)
208+
listed = await _send_recv(proc, "prompts/list", {}, 2)
209+
items = listed["result"]["prompts"]
210+
assert any(p["name"] == "summarize" for p in items)
211+
sumarize = next(p for p in items if p["name"] == "summarize")
212+
assert sumarize["arguments"][0]["name"] == "text"
213+
assert sumarize["arguments"][0]["required"] is True
214+
215+
got = await _send_recv(proc, "prompts/get",
216+
{"name": "summarize",
217+
"arguments": {"text": "the rain in spain"}},
218+
3)
219+
msgs = got["result"]["messages"]
220+
assert msgs[0]["role"] == "user"
221+
# MCP message content shape: {type: "text", text: "..."} OR plain string
222+
content = msgs[0]["content"]
223+
text = content if isinstance(content, str) else content.get("text", "")
224+
assert "the rain in spain" in text
225+
226+
# Missing required arg
227+
bad = await _send_recv(proc, "prompts/get",
228+
{"name": "summarize", "arguments": {}}, 4)
229+
assert "error" in bad
230+
231+
# Unknown prompt
232+
nope = await _send_recv(proc, "prompts/get",
233+
{"name": "doesnt-exist", "arguments": {}}, 5)
234+
assert "error" in nope
235+
finally:
236+
proc.terminate()
237+
try:
238+
await asyncio.wait_for(proc.wait(), timeout=3.0)
239+
except asyncio.TimeoutError:
240+
proc.kill()
241+
await proc.wait()

zhub/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ def publish(
119119
api_key: Optional[str] = None,
120120
private_key: Optional[str] = None,
121121
rate_limit: str = "60/min",
122+
resources: Optional[list[dict[str, Any]]] = None,
123+
prompts: Optional[list[dict[str, Any]]] = None,
122124
) -> ZhubPublication:
123125
"""Create a ZhubPublication. Call .run_forever() to actually start serving.
124126
@@ -136,6 +138,7 @@ def publish(
136138
name=name, description=description,
137139
operator=operator, contact=contact, public=public,
138140
rate_limit=rate_limit,
141+
resources=resources, prompts=prompts,
139142
)
140143
if capabilities:
141144
manifest.capabilities.extend(capabilities)

zhub/entity.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,10 @@ Per-AI model list. Single entry. Use when base URL is `<hub>/<ai>/v1`.
113113

114114
### `GET /<ai>/manifest.json`
115115
Publisher's full manifest: name, description, capabilities, signed
116-
status, public_key, connected clients with their capabilities. Use this
117-
to discover what an AI is and what tools its connections expose.
116+
status, public_key, connected clients with their capabilities, plus
117+
optional `resources` and `prompts` arrays declared at publish time
118+
(Phase 9.0). The MCP server bridge (`zhub.mcp_server`) reads this on
119+
each `*/list` to surface the AI's resources + prompts to MCP hosts.
118120

119121
### `GET /registry`
120122
Public listing of currently-registered publishers (only ones marked

zhub/manifest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ class Manifest:
5656
public: bool = False # listed in public registry?
5757
operator: str = "" # who runs this AI
5858
contact: str = "" # how to reach the operator
59+
# Phase 9.0 — MCP resources + prompts surface, declared inline.
60+
# Each resource: {uri, name, description?, mimeType?, content}
61+
# Each prompt: {name, description?, arguments?: [{name, required?, description?}],
62+
# messages: [{role, content (str)}] with {var} placeholders}
63+
resources: list[dict[str, Any]] = field(default_factory=list)
64+
prompts: list[dict[str, Any]] = field(default_factory=list)
5965
extensions: dict[str, Any] = field(default_factory=dict)
6066

6167
def to_dict(self) -> dict[str, Any]:
@@ -88,13 +94,17 @@ def chat_only_manifest(
8894
contact: str = "",
8995
public: bool = False,
9096
rate_limit: str = "60/min",
97+
resources: Optional[list[dict[str, Any]]] = None,
98+
prompts: Optional[list[dict[str, Any]]] = None,
9199
) -> Manifest:
92100
"""The simplest possible manifest — an AI that only does chat."""
93101
return Manifest(
94102
name=name,
95103
description=description,
96104
accepts="openai-v1-chat-completions",
97105
rate_limit=rate_limit,
106+
resources=resources or [],
107+
prompts=prompts or [],
98108
capabilities=[
99109
Capability(
100110
name="chat",

0 commit comments

Comments
 (0)