Spaces:
Running
feat(mport): an MCP server is a capability surface; its bytes come back addressable
Browse filesMPort already turned an origin into coordinates by reading OpenAPI or a
manifest. An MCP server offers something better: tools that describe
themselves, with input schemas, written to be called. So one tool becomes one
coordinate and nothing else in MPort changes — mock, dry_run, live, SEC-Ω, rate
limits, circuit breakers, retries, envelopes and broadcast all still apply,
because an MCP call is an ordinary POST once the body is shaped right.
Streamable HTTP, both framings: a server may answer JSON-RPC as application/json
or as a text/event-stream carrying the same frame, and the session id it issues
must ride every later request. Tests run against a real server over real HTTP,
parameterised across both transports.
Bytes never enter the mesh. A tool that returns content hands back base64; that
becomes a stored blob behind the existing snapshot store and travels as a URL.
The mesh routes to content, it does not carry it — base64 in a broadcast would
put megabytes on every antenna tuned to the frequency to deliver one artifact.
And it is not an image feature. MCP's `resource` block carries arbitrary bytes
with a mime type — a render, a mesh, a wasm module, a video — so those are
decoded and stored on the same path, `resource_link` is carried as an address,
and both the extension and the served content-type come from stdlib mimetypes
instead of a hand-written list of picture formats. Anything meshable is
addressable; anything addressable can be reached from wherever you are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGo2kuduWZMmEZGwKVNryU
- core/mcp_client.py +306 -0
- core/submesh.py +90 -5
- tests/test_mcp_wrap.py +261 -0
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/mcp_client.py — an MCP server, read as a capability surface.
|
| 3 |
+
|
| 4 |
+
MPort already turns an origin into coordinates: OpenAPI gives it paths, a
|
| 5 |
+
manifest gives it ops. An MCP server gives it something better — a list of
|
| 6 |
+
tools that describe themselves, with input schemas, written to be called.
|
| 7 |
+
|
| 8 |
+
So the mapping is direct: one MCP tool becomes one coordinate. Nothing else in
|
| 9 |
+
MPort changes. mock, dry_run, live, SEC-Ω, rate limits, circuit breakers,
|
| 10 |
+
retries, envelopes and broadcast all still apply, because an MCP call is an
|
| 11 |
+
ordinary POST once the body is shaped right.
|
| 12 |
+
|
| 13 |
+
Transport is Streamable HTTP (2025-03-26): one endpoint, POST JSON-RPC, and the
|
| 14 |
+
server answers with either application/json or a text/event-stream carrying the
|
| 15 |
+
same JSON-RPC frame. Both are handled here so callers never see the difference.
|
| 16 |
+
|
| 17 |
+
What this module deliberately does NOT do: put bytes in the mesh. A tool that
|
| 18 |
+
returns an image hands back base64; that becomes a stored blob and a URL, and
|
| 19 |
+
only the URL travels as a signal. The mesh routes to content, it does not carry it.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import base64
|
| 24 |
+
import json
|
| 25 |
+
import re
|
| 26 |
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
| 27 |
+
|
| 28 |
+
import httpx
|
| 29 |
+
|
| 30 |
+
PROTOCOL_VERSION = "2025-03-26"
|
| 31 |
+
_ACCEPT = "application/json, text/event-stream"
|
| 32 |
+
|
| 33 |
+
# An MCP tool name is already an identifier; keep it recognisable in the coord.
|
| 34 |
+
_IDENT = re.compile(r"[^a-zA-Z0-9_]+")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _ident(text: str) -> str:
|
| 38 |
+
return _IDENT.sub("_", (text or "").strip()).strip("_").lower() or "tool"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _parse_sse(body: str) -> Optional[dict]:
|
| 42 |
+
"""Pull the first JSON-RPC frame out of an event stream."""
|
| 43 |
+
for line in body.splitlines():
|
| 44 |
+
if line.startswith("data:"):
|
| 45 |
+
chunk = line[5:].strip()
|
| 46 |
+
if chunk and chunk != "[DONE]":
|
| 47 |
+
try:
|
| 48 |
+
return json.loads(chunk)
|
| 49 |
+
except json.JSONDecodeError:
|
| 50 |
+
continue
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def parse_response(content_type: str, text: str) -> dict:
|
| 55 |
+
"""One JSON-RPC frame, whichever transport the server chose."""
|
| 56 |
+
if "text/event-stream" in (content_type or ""):
|
| 57 |
+
frame = _parse_sse(text)
|
| 58 |
+
if frame is None:
|
| 59 |
+
raise ValueError("event stream carried no JSON-RPC frame")
|
| 60 |
+
return frame
|
| 61 |
+
return json.loads(text)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def rpc_body(method: str, params: Optional[dict] = None, rpc_id: Any = 1) -> dict:
|
| 65 |
+
body: Dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
| 66 |
+
if rpc_id is not None:
|
| 67 |
+
body["id"] = rpc_id
|
| 68 |
+
if params is not None:
|
| 69 |
+
body["params"] = params
|
| 70 |
+
return body
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def call_body(tool: str, arguments: Optional[dict]) -> dict:
|
| 74 |
+
"""The body that invokes one tool. This is what MPort POSTs in live mode."""
|
| 75 |
+
return rpc_body("tools/call", {"name": tool, "arguments": arguments or {}})
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def _post(client: httpx.AsyncClient, endpoint: str, body: dict,
|
| 79 |
+
headers: Dict[str, str]) -> Tuple[Optional[dict], httpx.Response]:
|
| 80 |
+
r = await client.post(endpoint, json=body, headers=headers)
|
| 81 |
+
if r.status_code >= 400:
|
| 82 |
+
return None, r
|
| 83 |
+
if not (r.text or "").strip(): # notifications answer 202, no body
|
| 84 |
+
return None, r
|
| 85 |
+
return parse_response(r.headers.get("content-type", ""), r.text), r
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def discover(endpoint: str, headers: Optional[Dict[str, str]] = None,
|
| 89 |
+
timeout: float = 20.0) -> Dict[str, Any]:
|
| 90 |
+
"""Handshake, then ask the server what it can do.
|
| 91 |
+
|
| 92 |
+
Returns the server's own words: its name, its protocol version, and its
|
| 93 |
+
tools with their input schemas. Raises on a server that will not talk.
|
| 94 |
+
"""
|
| 95 |
+
hdrs = {"Content-Type": "application/json", "Accept": _ACCEPT,
|
| 96 |
+
"MCP-Protocol-Version": PROTOCOL_VERSION}
|
| 97 |
+
hdrs.update(headers or {})
|
| 98 |
+
|
| 99 |
+
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client:
|
| 100 |
+
init, resp = await _post(client, endpoint, rpc_body("initialize", {
|
| 101 |
+
"protocolVersion": PROTOCOL_VERSION,
|
| 102 |
+
"capabilities": {},
|
| 103 |
+
"clientInfo": {"name": "signalmesh-mport", "version": "2.0"},
|
| 104 |
+
}), hdrs)
|
| 105 |
+
if init is None:
|
| 106 |
+
raise ValueError(f"initialize failed: HTTP {resp.status_code}: "
|
| 107 |
+
f"{(resp.text or '')[:200]}")
|
| 108 |
+
if "error" in init:
|
| 109 |
+
raise ValueError(f"initialize rejected: {json.dumps(init['error'])[:200]}")
|
| 110 |
+
|
| 111 |
+
# The session id, when the server issues one, must ride every later
|
| 112 |
+
# request or the server treats us as a stranger and 404s the session.
|
| 113 |
+
session_id = resp.headers.get("mcp-session-id") or ""
|
| 114 |
+
if session_id:
|
| 115 |
+
hdrs["Mcp-Session-Id"] = session_id
|
| 116 |
+
|
| 117 |
+
await _post(client, endpoint, rpc_body("notifications/initialized", rpc_id=None), hdrs)
|
| 118 |
+
|
| 119 |
+
listed, resp = await _post(client, endpoint, rpc_body("tools/list", {}, 2), hdrs)
|
| 120 |
+
if listed is None or "error" in (listed or {}):
|
| 121 |
+
detail = json.dumps((listed or {}).get("error", {}))[:200] or f"HTTP {resp.status_code}"
|
| 122 |
+
raise ValueError(f"tools/list failed: {detail}")
|
| 123 |
+
|
| 124 |
+
info = (init.get("result") or {}).get("serverInfo") or {}
|
| 125 |
+
return {
|
| 126 |
+
"server": info.get("name") or "",
|
| 127 |
+
"version": info.get("version") or "",
|
| 128 |
+
"protocol": (init.get("result") or {}).get("protocolVersion") or "",
|
| 129 |
+
"session_id": session_id,
|
| 130 |
+
"tools": (listed.get("result") or {}).get("tools") or [],
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def coords_from_tools(slug: str, endpoint: str, tools: List[dict],
|
| 135 |
+
session_id: str = "") -> List[dict]:
|
| 136 |
+
"""One tool, one coordinate — carrying the tool's own schema, not a guess."""
|
| 137 |
+
out: List[dict] = []
|
| 138 |
+
for t in tools:
|
| 139 |
+
if not isinstance(t, dict) or not t.get("name"):
|
| 140 |
+
continue
|
| 141 |
+
name = str(t["name"])
|
| 142 |
+
schema = t.get("inputSchema") or {}
|
| 143 |
+
props = schema.get("properties") or {}
|
| 144 |
+
out.append({
|
| 145 |
+
"coord": f"submesh.{slug}.{_ident(name)}"[:140],
|
| 146 |
+
"method": "POST",
|
| 147 |
+
"url": endpoint,
|
| 148 |
+
"summary": (t.get("description") or "").strip().split("\n\n")[0][:300],
|
| 149 |
+
"params": list(props),
|
| 150 |
+
"requires_body": True,
|
| 151 |
+
"protocol": "mcp",
|
| 152 |
+
"tool": name,
|
| 153 |
+
"input_schema": schema,
|
| 154 |
+
"required": list(schema.get("required") or []),
|
| 155 |
+
"session_id": session_id,
|
| 156 |
+
})
|
| 157 |
+
return out
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _decode(raw: str, mime: str, kind: str, uri: str,
|
| 161 |
+
store_blob: Optional[Callable[[bytes, str], str]]) -> Dict[str, Any]:
|
| 162 |
+
entry: Dict[str, Any] = {"mime": mime, "kind": kind}
|
| 163 |
+
if uri:
|
| 164 |
+
entry["uri"] = uri
|
| 165 |
+
try:
|
| 166 |
+
data = base64.b64decode(raw, validate=True)
|
| 167 |
+
entry["bytes"] = len(data)
|
| 168 |
+
if store_blob:
|
| 169 |
+
entry["url"] = store_blob(data, mime)
|
| 170 |
+
else: # no store: say so, don't inline megabytes
|
| 171 |
+
entry["url"] = ""
|
| 172 |
+
entry["note"] = "not stored — no blob store supplied"
|
| 173 |
+
except Exception as e:
|
| 174 |
+
entry["error"] = f"undecodable {kind} payload: {type(e).__name__}"
|
| 175 |
+
return entry
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def normalize(frame: dict, store_blob: Optional[Callable[[bytes, str], str]] = None
|
| 179 |
+
) -> Dict[str, Any]:
|
| 180 |
+
"""Turn a tools/call frame into something a signal can carry.
|
| 181 |
+
|
| 182 |
+
Text stays text. Bytes — of any kind — are written through `store_blob` and
|
| 183 |
+
come back as a URL. That is the whole rule, and it is not about pictures: a
|
| 184 |
+
render, a mesh, a wasm module, a sound, a PDF all arrive the same way, and
|
| 185 |
+
all become addressable the same way. Base64 in a broadcast would put
|
| 186 |
+
megabytes on every antenna tuned to the frequency to deliver one artifact.
|
| 187 |
+
"""
|
| 188 |
+
if "error" in frame:
|
| 189 |
+
err = frame["error"] or {}
|
| 190 |
+
return {"ok": False, "error": err.get("message") or "mcp error",
|
| 191 |
+
"code": err.get("code"), "text": "", "artifacts": [], "blocks": 0}
|
| 192 |
+
|
| 193 |
+
result = frame.get("result") or {}
|
| 194 |
+
texts: List[str] = []
|
| 195 |
+
artifacts: List[Dict[str, Any]] = []
|
| 196 |
+
blocks = result.get("content") or []
|
| 197 |
+
|
| 198 |
+
for b in blocks:
|
| 199 |
+
if not isinstance(b, dict):
|
| 200 |
+
continue
|
| 201 |
+
kind = b.get("type")
|
| 202 |
+
if kind == "text":
|
| 203 |
+
texts.append(str(b.get("text") or ""))
|
| 204 |
+
|
| 205 |
+
elif kind in ("image", "audio"):
|
| 206 |
+
artifacts.append(_decode(b.get("data") or "",
|
| 207 |
+
b.get("mimeType") or "application/octet-stream",
|
| 208 |
+
kind, "", store_blob))
|
| 209 |
+
|
| 210 |
+
elif kind == "resource":
|
| 211 |
+
# The general case. `blob` is arbitrary bytes with a mime type —
|
| 212 |
+
# glb, wasm, mp4, a Blender frame. Dropping it would make this an
|
| 213 |
+
# image feature instead of a transport.
|
| 214 |
+
res = b.get("resource") or {}
|
| 215 |
+
if res.get("blob"):
|
| 216 |
+
artifacts.append(_decode(res["blob"],
|
| 217 |
+
res.get("mimeType") or "application/octet-stream",
|
| 218 |
+
"resource", str(res.get("uri") or ""), store_blob))
|
| 219 |
+
elif res.get("text"):
|
| 220 |
+
texts.append(str(res["text"]))
|
| 221 |
+
elif res.get("uri"):
|
| 222 |
+
texts.append(str(res["uri"]))
|
| 223 |
+
|
| 224 |
+
elif kind == "resource_link":
|
| 225 |
+
# Already addressable at the origin; carry the address, fetch nothing.
|
| 226 |
+
artifacts.append({"kind": "link", "url": str(b.get("uri") or ""),
|
| 227 |
+
"mime": b.get("mimeType") or "",
|
| 228 |
+
"name": b.get("name") or ""})
|
| 229 |
+
|
| 230 |
+
return {
|
| 231 |
+
"ok": not result.get("isError", False),
|
| 232 |
+
"text": "\n".join(t for t in texts if t).strip(),
|
| 233 |
+
"artifacts": artifacts,
|
| 234 |
+
"structured": result.get("structuredContent"),
|
| 235 |
+
"blocks": len(blocks),
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if __name__ == "__main__":
|
| 240 |
+
png = base64.b64encode(b"\x89PNG\r\n\x1a\nFAKE").decode()
|
| 241 |
+
|
| 242 |
+
assert parse_response("text/event-stream",
|
| 243 |
+
'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n'
|
| 244 |
+
)["id"] == 1
|
| 245 |
+
assert parse_response("application/json", '{"jsonrpc":"2.0","id":2,"result":{}}')["id"] == 2
|
| 246 |
+
|
| 247 |
+
assert call_body("goto", {"url": "https://x"}) == {
|
| 248 |
+
"jsonrpc": "2.0", "method": "tools/call", "id": 1,
|
| 249 |
+
"params": {"name": "goto", "arguments": {"url": "https://x"}}}
|
| 250 |
+
assert "id" not in rpc_body("notifications/initialized", rpc_id=None)
|
| 251 |
+
|
| 252 |
+
coords = coords_from_tools("panda", "https://h/mcp", [
|
| 253 |
+
{"name": "screenshot", "description": "Grab the page.\n\nMore prose.",
|
| 254 |
+
"inputSchema": {"type": "object", "properties": {"url": {}, "full": {}},
|
| 255 |
+
"required": ["url"]}},
|
| 256 |
+
{"no_name": True},
|
| 257 |
+
])
|
| 258 |
+
assert len(coords) == 1, coords
|
| 259 |
+
c = coords[0]
|
| 260 |
+
assert c["coord"] == "submesh.panda.screenshot" and c["tool"] == "screenshot"
|
| 261 |
+
assert c["params"] == ["url", "full"] and c["required"] == ["url"]
|
| 262 |
+
assert c["summary"] == "Grab the page." and c["protocol"] == "mcp"
|
| 263 |
+
|
| 264 |
+
kept: List[Tuple[bytes, str]] = []
|
| 265 |
+
def _store(data: bytes, mime: str) -> str:
|
| 266 |
+
kept.append((data, mime))
|
| 267 |
+
return f"/blob/{len(kept)}"
|
| 268 |
+
|
| 269 |
+
out = normalize({"result": {"content": [
|
| 270 |
+
{"type": "text", "text": "here it is"},
|
| 271 |
+
{"type": "image", "data": png, "mimeType": "image/png"},
|
| 272 |
+
{"type": "resource", "resource": {"uri": "https://x/y.png"}},
|
| 273 |
+
]}}, _store)
|
| 274 |
+
assert out["ok"] and out["blocks"] == 3
|
| 275 |
+
assert out["text"] == "here it is\nhttps://x/y.png", out["text"]
|
| 276 |
+
assert out["artifacts"] == [{"mime": "image/png", "kind": "image",
|
| 277 |
+
"bytes": 12, "url": "/blob/1"}], out["artifacts"]
|
| 278 |
+
assert kept[0][0].startswith(b"\x89PNG"), "stored bytes are the decoded image"
|
| 279 |
+
|
| 280 |
+
# base64 must never survive into the envelope — that is what the store is for
|
| 281 |
+
assert png not in json.dumps(out)
|
| 282 |
+
|
| 283 |
+
# bytes are bytes: a glb resource must survive exactly like a png did
|
| 284 |
+
glb = base64.b64encode(b"glTF\x02\x00\x00\x00mesh").decode()
|
| 285 |
+
out = normalize({"result": {"content": [
|
| 286 |
+
{"type": "resource", "resource": {"uri": "file:///scene.glb", "blob": glb,
|
| 287 |
+
"mimeType": "model/gltf-binary"}},
|
| 288 |
+
{"type": "resource_link", "uri": "https://x/live.webm",
|
| 289 |
+
"mimeType": "video/webm", "name": "viewport"},
|
| 290 |
+
]}}, _store)
|
| 291 |
+
assert out["artifacts"][0] == {"mime": "model/gltf-binary", "kind": "resource",
|
| 292 |
+
"uri": "file:///scene.glb", "bytes": 12,
|
| 293 |
+
"url": "/blob/2"}, out["artifacts"][0]
|
| 294 |
+
assert kept[1][0].startswith(b"glTF"), "a 3D scene round-trips like any other bytes"
|
| 295 |
+
assert out["artifacts"][1] == {"kind": "link", "url": "https://x/live.webm",
|
| 296 |
+
"mime": "video/webm", "name": "viewport"}
|
| 297 |
+
assert glb not in json.dumps(out)
|
| 298 |
+
|
| 299 |
+
assert normalize({"result": {"content": [
|
| 300 |
+
{"type": "image", "data": "!!not base64!!", "mimeType": "image/png"}]}}, _store
|
| 301 |
+
)["artifacts"][0]["error"].startswith("undecodable")
|
| 302 |
+
|
| 303 |
+
err = normalize({"error": {"code": -32602, "message": "no such tool"}})
|
| 304 |
+
assert err["ok"] is False and err["code"] == -32602
|
| 305 |
+
|
| 306 |
+
print("mcp_client self-check OK")
|
|
@@ -55,6 +55,7 @@ from __future__ import annotations
|
|
| 55 |
import asyncio
|
| 56 |
import hashlib
|
| 57 |
import json
|
|
|
|
| 58 |
import os
|
| 59 |
import queue
|
| 60 |
import random
|
|
@@ -71,6 +72,7 @@ from fastapi.responses import HTMLResponse, StreamingResponse
|
|
| 71 |
from pydantic import BaseModel, Field
|
| 72 |
from core.source_hydrator import is_fetchable
|
| 73 |
from core import dbmesh
|
|
|
|
| 74 |
|
| 75 |
# ── Nicoli static harness catalog (bundled from cli-anything upstream) ────────
|
| 76 |
try:
|
|
@@ -96,7 +98,7 @@ _PROMOTION_TTL_S = 15 * 60 # dry_run promotions expire in 15 min
|
|
| 96 |
|
| 97 |
class WrapReq(BaseModel):
|
| 98 |
origin: str
|
| 99 |
-
kind: str = "url" # "url" | "manifest" | "nicoli"
|
| 100 |
cookies: Any = None
|
| 101 |
headers: Any = None
|
| 102 |
manifest: Optional[dict] = None
|
|
@@ -1634,7 +1636,58 @@ def _guess_ct(path: str) -> str:
|
|
| 1634 |
(".woff2", "font/woff2"), (".woff", "font/woff"), (".ttf", "font/ttf")):
|
| 1635 |
if p.endswith(ext):
|
| 1636 |
return ct
|
| 1637 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1638 |
|
| 1639 |
|
| 1640 |
def _bundle_pages(files: dict):
|
|
@@ -3374,7 +3427,25 @@ def make_router(*, check_key, signal_registry, sec_omega, socket_limit=None) ->
|
|
| 3374 |
|
| 3375 |
ops: List[dict] = []
|
| 3376 |
source = "default"
|
| 3377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3378 |
ops = _coords_from_manifest(slug, base, req.manifest)
|
| 3379 |
source = "manifest"
|
| 3380 |
if not ops and req.kind == "url" and req.scan_openapi:
|
|
@@ -3879,13 +3950,22 @@ def make_router(*, check_key, signal_registry, sec_omega, socket_limit=None) ->
|
|
| 3879 |
_path_keys.add(k)
|
| 3880 |
params: dict = {}
|
| 3881 |
body: Optional[dict] = None
|
| 3882 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3883 |
params = {k: v for k, v in (req.input or {}).items()
|
| 3884 |
if k not in _path_keys and not str(k).startswith("_")}
|
| 3885 |
else:
|
| 3886 |
body = req.input or None
|
| 3887 |
|
| 3888 |
hdrs = {"User-Agent": "SignalMesh-Submesh/0"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3889 |
for h in headers:
|
| 3890 |
try:
|
| 3891 |
hdrs[str(h["name"])] = str(h["value"])
|
|
@@ -3992,7 +4072,12 @@ def make_router(*, check_key, signal_registry, sec_omega, socket_limit=None) ->
|
|
| 3992 |
cookies=jar, headers=hdrs) as client:
|
| 3993 |
r = await client.request(method, url, params=params, json=body)
|
| 3994 |
ct = r.headers.get("content-type", "")
|
| 3995 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3996 |
payload_type, payload = "json", r.json()
|
| 3997 |
else:
|
| 3998 |
payload_type, payload = "text", r.text[:20000]
|
|
|
|
| 55 |
import asyncio
|
| 56 |
import hashlib
|
| 57 |
import json
|
| 58 |
+
import mimetypes
|
| 59 |
import os
|
| 60 |
import queue
|
| 61 |
import random
|
|
|
|
| 72 |
from pydantic import BaseModel, Field
|
| 73 |
from core.source_hydrator import is_fetchable
|
| 74 |
from core import dbmesh
|
| 75 |
+
from core import mcp_client
|
| 76 |
|
| 77 |
# ── Nicoli static harness catalog (bundled from cli-anything upstream) ────────
|
| 78 |
try:
|
|
|
|
| 98 |
|
| 99 |
class WrapReq(BaseModel):
|
| 100 |
origin: str
|
| 101 |
+
kind: str = "url" # "url" | "manifest" | "nicoli" | "mcp"
|
| 102 |
cookies: Any = None
|
| 103 |
headers: Any = None
|
| 104 |
manifest: Optional[dict] = None
|
|
|
|
| 1636 |
(".woff2", "font/woff2"), (".woff", "font/woff"), (".ttf", "font/ttf")):
|
| 1637 |
if p.endswith(ext):
|
| 1638 |
return ct
|
| 1639 |
+
# Anything else addressable — glb, wasm, webm, mp4, stl — rather than
|
| 1640 |
+
# handing every unlisted type back as a download.
|
| 1641 |
+
for mime, extension in _MCP_MIME_EXT.items():
|
| 1642 |
+
if p.endswith("." + extension):
|
| 1643 |
+
return mime
|
| 1644 |
+
guess, _ = mimetypes.guess_type(p)
|
| 1645 |
+
return guess or "application/octet-stream"
|
| 1646 |
+
|
| 1647 |
+
|
| 1648 |
+
# Only what stdlib mimetypes does not already know. Everything else — png, svg,
|
| 1649 |
+
# wav, mp4, pdf, json — it maps for us, and it is not an image-only list.
|
| 1650 |
+
_MCP_MIME_EXT = {"model/gltf-binary": "glb", "model/gltf+json": "gltf",
|
| 1651 |
+
"model/stl": "stl", "model/obj": "obj",
|
| 1652 |
+
"application/wasm": "wasm", "video/webm": "webm",
|
| 1653 |
+
"image/webp": "webp", "application/x-blender": "blend"}
|
| 1654 |
+
|
| 1655 |
+
|
| 1656 |
+
def _mcp_ext(mime: str) -> str:
|
| 1657 |
+
known = _MCP_MIME_EXT.get((mime or "").lower())
|
| 1658 |
+
if known:
|
| 1659 |
+
return known
|
| 1660 |
+
guess = mimetypes.guess_extension((mime or "").split(";")[0].strip() or "")
|
| 1661 |
+
return guess.lstrip(".") if guess else "bin"
|
| 1662 |
+
|
| 1663 |
+
|
| 1664 |
+
_MCP_BLOBS_PER_SLUG = 64
|
| 1665 |
+
|
| 1666 |
+
|
| 1667 |
+
def _store_mcp_blob(data: bytes, mime: str, slug: str) -> str:
|
| 1668 |
+
"""An MCP tool handed back bytes. Park them, hand back a URL.
|
| 1669 |
+
|
| 1670 |
+
Reuses the snapshot bundle store: it is already a TTL'd, capped,
|
| 1671 |
+
content-typed blob store with a keyless read route in front of it, which is
|
| 1672 |
+
exactly what an image needs to be viewable from an antenna or a chat bubble.
|
| 1673 |
+
The mesh gets this URL; it never gets the bytes.
|
| 1674 |
+
|
| 1675 |
+
One bundle per origin, not one per image — _SNAPSHOT_MAX counts bundles, and
|
| 1676 |
+
a token per picture would evict a site capture after four screenshots.
|
| 1677 |
+
"""
|
| 1678 |
+
target = f"mcp:{slug}"
|
| 1679 |
+
name = f"{uuid.uuid4().hex[:8]}.{_mcp_ext(mime)}"
|
| 1680 |
+
token = next((t for t, s in _SNAPSHOTS.items() if s.get("target") == target), "")
|
| 1681 |
+
if token:
|
| 1682 |
+
files = _SNAPSHOTS[token]["files"]
|
| 1683 |
+
# insertion order, not sorted() — the names are random, the order is not
|
| 1684 |
+
for old in list(files)[:max(0, len(files) + 1 - _MCP_BLOBS_PER_SLUG)]:
|
| 1685 |
+
files.pop(old, None)
|
| 1686 |
+
files[name] = data
|
| 1687 |
+
_SNAPSHOTS[token]["created"] = time.time() # keep a live origin alive
|
| 1688 |
+
else:
|
| 1689 |
+
token = _store_snapshot({name: data}, host=slug, target=target)
|
| 1690 |
+
return f"/api/submesh/preview/{token}/{name}"
|
| 1691 |
|
| 1692 |
|
| 1693 |
def _bundle_pages(files: dict):
|
|
|
|
| 3427 |
|
| 3428 |
ops: List[dict] = []
|
| 3429 |
source = "default"
|
| 3430 |
+
|
| 3431 |
+
# ── kind="mcp": the server describes its own callable surface ───
|
| 3432 |
+
# No guessing from paths — tools/list is the manifest, written by the
|
| 3433 |
+
# people who built the thing. One tool becomes one coordinate.
|
| 3434 |
+
if req.kind == "mcp":
|
| 3435 |
+
try:
|
| 3436 |
+
found = await mcp_client.discover(
|
| 3437 |
+
origin, {h["name"]: h["value"] for h in _normalize_headers(req.headers)})
|
| 3438 |
+
except Exception as e:
|
| 3439 |
+
raise HTTPException(502, f"MCP handshake failed at {origin}: "
|
| 3440 |
+
f"{type(e).__name__}: {str(e)[:200]}")
|
| 3441 |
+
ops = mcp_client.coords_from_tools(slug, origin, found["tools"],
|
| 3442 |
+
found.get("session_id", ""))
|
| 3443 |
+
if not ops:
|
| 3444 |
+
raise HTTPException(422, f"{origin} answered tools/list with no tools — "
|
| 3445 |
+
"nothing to register.")
|
| 3446 |
+
base, source = origin, "mcp"
|
| 3447 |
+
|
| 3448 |
+
if not ops and req.manifest:
|
| 3449 |
ops = _coords_from_manifest(slug, base, req.manifest)
|
| 3450 |
source = "manifest"
|
| 3451 |
if not ops and req.kind == "url" and req.scan_openapi:
|
|
|
|
| 3950 |
_path_keys.add(k)
|
| 3951 |
params: dict = {}
|
| 3952 |
body: Optional[dict] = None
|
| 3953 |
+
is_mcp = op.get("protocol") == "mcp"
|
| 3954 |
+
if is_mcp:
|
| 3955 |
+
# The caller names arguments; JSON-RPC framing is ours to add.
|
| 3956 |
+
body = mcp_client.call_body(op["tool"], req.input or {})
|
| 3957 |
+
elif method == "GET":
|
| 3958 |
params = {k: v for k, v in (req.input or {}).items()
|
| 3959 |
if k not in _path_keys and not str(k).startswith("_")}
|
| 3960 |
else:
|
| 3961 |
body = req.input or None
|
| 3962 |
|
| 3963 |
hdrs = {"User-Agent": "SignalMesh-Submesh/0"}
|
| 3964 |
+
if is_mcp:
|
| 3965 |
+
hdrs["Accept"] = "application/json, text/event-stream"
|
| 3966 |
+
hdrs["MCP-Protocol-Version"] = mcp_client.PROTOCOL_VERSION
|
| 3967 |
+
if op.get("session_id"):
|
| 3968 |
+
hdrs["Mcp-Session-Id"] = op["session_id"]
|
| 3969 |
for h in headers:
|
| 3970 |
try:
|
| 3971 |
hdrs[str(h["name"])] = str(h["value"])
|
|
|
|
| 4072 |
cookies=jar, headers=hdrs) as client:
|
| 4073 |
r = await client.request(method, url, params=params, json=body)
|
| 4074 |
ct = r.headers.get("content-type", "")
|
| 4075 |
+
if is_mcp and r.status_code < 400:
|
| 4076 |
+
payload_type = "mcp"
|
| 4077 |
+
payload = mcp_client.normalize(
|
| 4078 |
+
mcp_client.parse_response(ct, r.text),
|
| 4079 |
+
lambda data, mime: _store_mcp_blob(data, mime, slug))
|
| 4080 |
+
elif "application/json" in ct:
|
| 4081 |
payload_type, payload = "json", r.json()
|
| 4082 |
else:
|
| 4083 |
payload_type, payload = "text", r.text[:20000]
|
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wrapping an MCP server: its tools become coordinates, and an image comes back.
|
| 2 |
+
|
| 3 |
+
Runs against a real MCP server over real HTTP — a small one, started in-process,
|
| 4 |
+
that speaks Streamable HTTP and hands back a genuine PNG. The point is the whole
|
| 5 |
+
round trip, not a mocked httpx: handshake, tools/list, coordinate registration,
|
| 6 |
+
tools/call, and an image that arrives as a fetchable URL rather than base64 in
|
| 7 |
+
the mesh.
|
| 8 |
+
"""
|
| 9 |
+
import base64
|
| 10 |
+
import json
|
| 11 |
+
import socket
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
import pytest
|
| 16 |
+
import uvicorn
|
| 17 |
+
from fastapi import FastAPI, Request
|
| 18 |
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
| 19 |
+
from fastapi.testclient import TestClient
|
| 20 |
+
|
| 21 |
+
import signalmesh_api as api
|
| 22 |
+
from core import submesh
|
| 23 |
+
|
| 24 |
+
# A 1x1 red PNG — small, real, and decodable by anything that renders images.
|
| 25 |
+
RED_DOT = base64.b64decode(
|
| 26 |
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
|
| 27 |
+
|
| 28 |
+
TOOLS = [
|
| 29 |
+
{"name": "fetch_page", "description": "Fetch a page as text.",
|
| 30 |
+
"inputSchema": {"type": "object", "properties": {"url": {"type": "string"}},
|
| 31 |
+
"required": ["url"]}},
|
| 32 |
+
{"name": "grab_image", "description": "Return the image at a URL.",
|
| 33 |
+
"inputSchema": {"type": "object", "properties": {"url": {"type": "string"}},
|
| 34 |
+
"required": ["url"]}},
|
| 35 |
+
{"name": "render_scene", "description": "Return a 3D scene as a binary resource.",
|
| 36 |
+
"inputSchema": {"type": "object", "properties": {"name": {"type": "string"}}}},
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
# Not an image. The point is that the transport does not care.
|
| 40 |
+
GLB = b"glTF\x02\x00\x00\x00" + b"\x00" * 40
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _make_server(sse: bool = False) -> FastAPI:
|
| 44 |
+
"""An MCP server. `sse=True` answers over text/event-stream instead of JSON."""
|
| 45 |
+
app = FastAPI()
|
| 46 |
+
|
| 47 |
+
def reply(frame: dict):
|
| 48 |
+
if sse:
|
| 49 |
+
return PlainTextResponse("event: message\ndata: " + json.dumps(frame) + "\n\n",
|
| 50 |
+
media_type="text/event-stream")
|
| 51 |
+
return JSONResponse(frame)
|
| 52 |
+
|
| 53 |
+
@app.post("/mcp")
|
| 54 |
+
async def mcp(request: Request):
|
| 55 |
+
body = await request.json()
|
| 56 |
+
method, rid = body.get("method"), body.get("id")
|
| 57 |
+
|
| 58 |
+
if method == "initialize":
|
| 59 |
+
r = JSONResponse({"jsonrpc": "2.0", "id": rid, "result": {
|
| 60 |
+
"protocolVersion": "2025-03-26", "capabilities": {"tools": {}},
|
| 61 |
+
"serverInfo": {"name": "test-panda", "version": "0.1"}}})
|
| 62 |
+
r.headers["Mcp-Session-Id"] = "sess-abc"
|
| 63 |
+
return r
|
| 64 |
+
|
| 65 |
+
if method == "notifications/initialized":
|
| 66 |
+
return PlainTextResponse("", status_code=202)
|
| 67 |
+
|
| 68 |
+
# Every post-handshake request must carry the session the server issued.
|
| 69 |
+
if request.headers.get("mcp-session-id") != "sess-abc":
|
| 70 |
+
return JSONResponse({"jsonrpc": "2.0", "id": rid, "error": {
|
| 71 |
+
"code": -32000, "message": "missing session"}}, status_code=400)
|
| 72 |
+
|
| 73 |
+
if method == "tools/list":
|
| 74 |
+
return reply({"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}})
|
| 75 |
+
|
| 76 |
+
if method == "tools/call":
|
| 77 |
+
p = body.get("params") or {}
|
| 78 |
+
name, args = p.get("name"), p.get("arguments") or {}
|
| 79 |
+
if name == "grab_image":
|
| 80 |
+
return reply({"jsonrpc": "2.0", "id": rid, "result": {"content": [
|
| 81 |
+
{"type": "text", "text": f"image from {args.get('url')}"},
|
| 82 |
+
{"type": "image",
|
| 83 |
+
"data": base64.b64encode(RED_DOT).decode(),
|
| 84 |
+
"mimeType": "image/png"}]}})
|
| 85 |
+
if name == "render_scene":
|
| 86 |
+
return reply({"jsonrpc": "2.0", "id": rid, "result": {"content": [
|
| 87 |
+
{"type": "resource", "resource": {
|
| 88 |
+
"uri": f"file:///{args.get('name', 'scene')}.glb",
|
| 89 |
+
"mimeType": "model/gltf-binary",
|
| 90 |
+
"blob": base64.b64encode(GLB).decode()}}]}})
|
| 91 |
+
if name == "fetch_page":
|
| 92 |
+
return reply({"jsonrpc": "2.0", "id": rid, "result": {"content": [
|
| 93 |
+
{"type": "text", "text": f"contents of {args.get('url')}"}]}})
|
| 94 |
+
return reply({"jsonrpc": "2.0", "id": rid, "error": {
|
| 95 |
+
"code": -32602, "message": f"no such tool: {name}"}})
|
| 96 |
+
|
| 97 |
+
return reply({"jsonrpc": "2.0", "id": rid, "error": {
|
| 98 |
+
"code": -32601, "message": method}})
|
| 99 |
+
|
| 100 |
+
return app
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _free_port() -> int:
|
| 104 |
+
with socket.socket() as s:
|
| 105 |
+
s.bind(("127.0.0.1", 0))
|
| 106 |
+
return s.getsockname()[1]
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@pytest.fixture(scope="module", params=[False, True], ids=["json", "sse"])
|
| 110 |
+
def mcp_url(request):
|
| 111 |
+
port = _free_port()
|
| 112 |
+
server = uvicorn.Server(uvicorn.Config(_make_server(request.param),
|
| 113 |
+
host="127.0.0.1", port=port,
|
| 114 |
+
log_level="critical"))
|
| 115 |
+
threading.Thread(target=server.run, daemon=True).start()
|
| 116 |
+
for _ in range(100):
|
| 117 |
+
if server.started:
|
| 118 |
+
break
|
| 119 |
+
time.sleep(0.05)
|
| 120 |
+
assert server.started, "test MCP server did not start"
|
| 121 |
+
yield f"http://127.0.0.1:{port}/mcp"
|
| 122 |
+
server.should_exit = True
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@pytest.fixture
|
| 126 |
+
def client(monkeypatch):
|
| 127 |
+
"""MPort routes are managed — wrapping an origin changes the registry.
|
| 128 |
+
|
| 129 |
+
The test MCP server is on loopback, which MPort refuses by default so a
|
| 130 |
+
keyless caller cannot aim it at private infrastructure. A self-hoster opts
|
| 131 |
+
in with the same flag; see test_private_mcp_origin_is_refused_by_default.
|
| 132 |
+
"""
|
| 133 |
+
monkeypatch.setenv("SIGNALMESH_ALLOW_PRIVATE_SOURCES", "1")
|
| 134 |
+
api._PAID_KEYS.add("test-paid-key")
|
| 135 |
+
try:
|
| 136 |
+
yield TestClient(api.app, headers={"X-SignalMesh-Key": "test-paid-key"})
|
| 137 |
+
finally:
|
| 138 |
+
api._PAID_KEYS.discard("test-paid-key")
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_tools_become_coordinates(client, mcp_url):
|
| 142 |
+
d = client.post("/api/submesh/wrap",
|
| 143 |
+
json={"origin": mcp_url, "kind": "mcp"}).json()
|
| 144 |
+
assert d["source"] == "mcp", d
|
| 145 |
+
coords = [c["coord"] if isinstance(c, dict) else c for c in d["coords"]]
|
| 146 |
+
assert any(c.endswith(".grab_image") for c in coords), coords
|
| 147 |
+
assert any(c.endswith(".fetch_page") for c in coords), coords
|
| 148 |
+
|
| 149 |
+
op = next(o for o in submesh._COORDS.values()
|
| 150 |
+
if o["op"].get("tool") == "grab_image")["op"]
|
| 151 |
+
assert op["protocol"] == "mcp" and op["required"] == ["url"]
|
| 152 |
+
assert op["summary"] == "Return the image at a URL."
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def test_live_call_brings_an_image_back_as_a_url(client, mcp_url):
|
| 156 |
+
d = client.post("/api/submesh/wrap",
|
| 157 |
+
json={"origin": mcp_url, "kind": "mcp"}).json()
|
| 158 |
+
coord = next(c["coord"] if isinstance(c, dict) else c
|
| 159 |
+
for c in d["coords"] if str(c).endswith(".grab_image"))
|
| 160 |
+
|
| 161 |
+
env = client.post("/api/submesh/call", json={
|
| 162 |
+
"coord": coord, "input": {"url": "https://example.com/red.png"},
|
| 163 |
+
"mode": "live"}).json()
|
| 164 |
+
|
| 165 |
+
assert env["status"] == 200, env
|
| 166 |
+
assert env["payload_type"] == "mcp"
|
| 167 |
+
payload = env["payload"]
|
| 168 |
+
assert payload["ok"] and payload["text"].startswith("image from")
|
| 169 |
+
|
| 170 |
+
img = payload["artifacts"][0]
|
| 171 |
+
assert img["mime"] == "image/png" and img["bytes"] == len(RED_DOT)
|
| 172 |
+
|
| 173 |
+
# The bytes are reachable, and they are the bytes the tool sent.
|
| 174 |
+
got = client.get(img["url"])
|
| 175 |
+
assert got.status_code == 200, img["url"]
|
| 176 |
+
assert got.content == RED_DOT
|
| 177 |
+
assert got.headers["content-type"] == "image/png"
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_any_bytes_come_back_addressable_not_just_images(client, mcp_url):
|
| 181 |
+
"""A 3D scene is not a picture, and nothing in the path should care.
|
| 182 |
+
|
| 183 |
+
MCP's `resource` block carries arbitrary bytes with a mime type — a render,
|
| 184 |
+
a mesh, a wasm module, a video. If only `image` blocks survived, this would
|
| 185 |
+
be an image feature instead of a transport.
|
| 186 |
+
"""
|
| 187 |
+
d = client.post("/api/submesh/wrap",
|
| 188 |
+
json={"origin": mcp_url, "kind": "mcp"}).json()
|
| 189 |
+
coord = next(c["coord"] if isinstance(c, dict) else c
|
| 190 |
+
for c in d["coords"] if str(c).endswith(".render_scene"))
|
| 191 |
+
|
| 192 |
+
env = client.post("/api/submesh/call", json={
|
| 193 |
+
"coord": coord, "input": {"name": "fireflies"}, "mode": "live"}).json()
|
| 194 |
+
|
| 195 |
+
art = env["payload"]["artifacts"][0]
|
| 196 |
+
assert art["mime"] == "model/gltf-binary" and art["bytes"] == len(GLB)
|
| 197 |
+
assert art["uri"] == "file:///fireflies.glb"
|
| 198 |
+
assert art["url"].endswith(".glb"), art["url"]
|
| 199 |
+
|
| 200 |
+
got = client.get(art["url"])
|
| 201 |
+
assert got.status_code == 200
|
| 202 |
+
assert got.content == GLB
|
| 203 |
+
# served as a scene, not as an anonymous download
|
| 204 |
+
assert got.headers["content-type"] == "model/gltf-binary"
|
| 205 |
+
assert base64.b64encode(GLB).decode() not in json.dumps(env)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_base64_never_reaches_the_mesh(client, mcp_url):
|
| 209 |
+
"""A signal routes to content; carrying it would put megabytes on every
|
| 210 |
+
antenna tuned to the frequency, to show one picture."""
|
| 211 |
+
d = client.post("/api/submesh/wrap",
|
| 212 |
+
json={"origin": mcp_url, "kind": "mcp"}).json()
|
| 213 |
+
coord = next(c["coord"] if isinstance(c, dict) else c
|
| 214 |
+
for c in d["coords"] if str(c).endswith(".grab_image"))
|
| 215 |
+
|
| 216 |
+
env = client.post("/api/submesh/call", json={
|
| 217 |
+
"coord": coord, "input": {"url": "https://example.com/red.png"},
|
| 218 |
+
"mode": "live"}).json()
|
| 219 |
+
|
| 220 |
+
encoded = base64.b64encode(RED_DOT).decode()
|
| 221 |
+
assert encoded not in json.dumps(env)
|
| 222 |
+
|
| 223 |
+
slug = env["origin_slug"]
|
| 224 |
+
signals = api.signal_registry.tune_in([f"submesh.call.{slug}"])
|
| 225 |
+
assert signals, "the call was never broadcast"
|
| 226 |
+
assert encoded not in json.dumps(signals, default=str)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def test_tool_error_is_reported_not_swallowed(client, mcp_url):
|
| 230 |
+
d = client.post("/api/submesh/wrap",
|
| 231 |
+
json={"origin": mcp_url, "kind": "mcp"}).json()
|
| 232 |
+
coord = next(c["coord"] if isinstance(c, dict) else c
|
| 233 |
+
for c in d["coords"] if str(c).endswith(".fetch_page"))
|
| 234 |
+
|
| 235 |
+
# Point a registered coordinate at a tool the server does not have.
|
| 236 |
+
submesh._COORDS[coord]["op"]["tool"] = "not_a_tool"
|
| 237 |
+
env = client.post("/api/submesh/call", json={
|
| 238 |
+
"coord": coord, "input": {}, "mode": "live"}).json()
|
| 239 |
+
assert env["payload"]["ok"] is False
|
| 240 |
+
assert "no such tool" in env["payload"]["error"]
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def test_private_mcp_origin_is_refused_by_default(monkeypatch, mcp_url):
|
| 244 |
+
"""Without the opt-in, an MCP origin on the private network is refused —
|
| 245 |
+
otherwise wrap becomes a way to reach infrastructure MPort should not see."""
|
| 246 |
+
monkeypatch.delenv("SIGNALMESH_ALLOW_PRIVATE_SOURCES", raising=False)
|
| 247 |
+
api._PAID_KEYS.add("test-paid-key")
|
| 248 |
+
try:
|
| 249 |
+
c = TestClient(api.app, headers={"X-SignalMesh-Key": "test-paid-key"})
|
| 250 |
+
r = c.post("/api/submesh/wrap", json={"origin": mcp_url, "kind": "mcp"})
|
| 251 |
+
assert r.status_code == 400
|
| 252 |
+
assert "public http(s)" in r.json()["detail"]
|
| 253 |
+
finally:
|
| 254 |
+
api._PAID_KEYS.discard("test-paid-key")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def test_wrapping_a_non_mcp_origin_fails_loudly(client):
|
| 258 |
+
r = client.post("/api/submesh/wrap",
|
| 259 |
+
json={"origin": "https://example.com/not-mcp", "kind": "mcp"})
|
| 260 |
+
assert r.status_code == 502
|
| 261 |
+
assert "MCP handshake failed" in r.json()["detail"]
|