KyklosIO Claude Opus 5 commited on
Commit
5db544e
·
1 Parent(s): 75eef0a

feat(mport): an MCP server is a capability surface; its bytes come back addressable

Browse files

MPort 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

Files changed (3) hide show
  1. core/mcp_client.py +306 -0
  2. core/submesh.py +90 -5
  3. tests/test_mcp_wrap.py +261 -0
core/mcp_client.py ADDED
@@ -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")
core/submesh.py CHANGED
@@ -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" (Phase 1)
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
- return "application/octet-stream"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if req.manifest:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if method == "GET":
 
 
 
 
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 "application/json" in ct:
 
 
 
 
 
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]
tests/test_mcp_wrap.py ADDED
@@ -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"]