nimblebrain-synapse 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ """nimblebrain-synapse — the server (Python) half of the Synapse cross-host UI framework.
2
+
3
+ Pairs with the `@nimblebrain/synapse` client (`connectUI` / `window.SynapseUI`).
4
+ See `SynapseUI` in `nimblebrain_synapse.server`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ from .server import (
12
+ DEFAULT_DATA_ELEMENT_ID,
13
+ MCPAPP_MIME,
14
+ MCPUI_MIME,
15
+ SKYBRIDGE_MIME,
16
+ SynapseUI,
17
+ )
18
+
19
+ __all__ = ["SynapseUI", "SKYBRIDGE_MIME", "MCPUI_MIME", "MCPAPP_MIME", "DEFAULT_DATA_ELEMENT_ID"]
20
+
21
+ # Derived from the installed distribution metadata, so it can't drift from
22
+ # pyproject's version. Falls back only when imported from an uninstalled source
23
+ # tree — the vendoring pattern this package exists to retire.
24
+ try:
25
+ __version__ = version("nimblebrain-synapse")
26
+ except PackageNotFoundError:
27
+ __version__ = "0.0.0+unknown"
28
+
29
+ # The `@nimblebrain/synapse` npm release the vendored client IIFE
30
+ # (`_assets/synapse-ui.iife.js`) was built from. This package versions
31
+ # independently of the JS one (different cadence, different consumers); the two
32
+ # meet only on the wire protocol. CI keeps this equal to the sibling package.json
33
+ # version (ci.yml build job), so the pin can't silently go stale.
34
+ __client_version__ = "0.12.0"
@@ -0,0 +1 @@
1
+ (function(){'use strict';var de={"--color-background-primary":"#ffffff","--color-background-secondary":"#fafafa","--color-background-tertiary":"#f3f4f6","--color-text-primary":"#111827","--color-text-secondary":"#6b7280","--color-text-tertiary":"#9ca3af","--color-text-accent":"#2563eb","--nb-color-accent-foreground":"#ffffff","--color-border-primary":"#e5e7eb","--color-border-secondary":"#d1d5db","--color-ring-primary":"#2563eb","--nb-color-danger":"#dc2626","--nb-color-success":"#059669","--nb-color-warning":"#f59e0b","--nb-color-warm":"#d4620a","--nb-color-warm-light":"#fef5ee","--nb-color-processing":"#7c3aed","--nb-color-processing-light":"#f3eeff","--nb-color-info-light":"#eef4ff"},ue={"--color-background-primary":"#18181b","--color-background-secondary":"#27272a","--color-background-tertiary":"#2f2f34","--color-text-primary":"#fafafa","--color-text-secondary":"#a1a1aa","--color-text-tertiary":"#71717a","--color-text-accent":"#818cf8","--nb-color-accent-foreground":"#ffffff","--color-border-primary":"#3f3f46","--color-border-secondary":"#52525b","--color-ring-primary":"#818cf8","--nb-color-danger":"#f87171","--nb-color-success":"#34d399","--nb-color-warning":"#fbbf24","--nb-color-warm":"#fb923c","--nb-color-warm-light":"#3a2a1e","--nb-color-processing":"#a78bfa","--nb-color-processing-light":"#2a2440","--nb-color-info-light":"#1e2a44"},pe={light:de,dark:ue};function N(t,r){if(typeof document>"u")return;let n=document.documentElement.style;for(let[i,l]of Object.entries(pe[t]))n.setProperty(i,l);if(r&&typeof r=="object")for(let[i,l]of Object.entries(r))typeof i=="string"&&typeof l=="string"&&n.setProperty(i,l);}function R(t){typeof document<"u"&&document.documentElement.setAttribute("data-theme",t.mode),N(t.mode,t.tokens);}function C(t){try{if(t?.matchMedia?.("(prefers-color-scheme: dark)").matches)return "dark"}catch{}return "light"}function h(t,r){return t==="light"||t==="dark"?t:r}var b=class extends Error{constructor(r,n){super(`"${r}" is not supported by the "${n}" host`),this.name="HostUnsupportedError";}},A="synapse-ui-data",z="ui-lifecycle-iframe-ready",j="ui-lifecycle-iframe-render-data",G="ui-size-change",K="link",W="prompt",S="openai:set_globals",F="2026-01-26",Z="ui/initialize",q="ui/notifications/initialized",Y="ui/notifications/tool-result",V="ui/notifications/host-context-changed",B="ui/notifications/size-changed",Q="ui/open-link",$="ui/message",X="ui/resource-teardown",J="tools/call";function ee(t,r){let n=()=>t.openai,i=n()?.toolOutput??null,l={mode:h(n()?.theme,"light"),tokens:{}},a=new Set,p=new Set,f=false,m=s=>{if(f)return;let c=s.detail?.globals;if(c){if("toolOutput"in c&&c.toolOutput!=null){i=c.toolOutput;for(let u of a)u(i);}if("theme"in c&&c.theme!=null){let u=h(c.theme,l.mode);if(u!==l.mode){l={mode:u,tokens:{}};for(let P of p)P(l);}}}};return {host:"chatgpt",getData:()=>i,onData(s){return a.add(s),()=>a.delete(s)},getTheme:()=>l,onTheme(s){return p.add(s),()=>p.delete(s)},async callTool(s,c){let u=n()?.callTool;if(!u)throw new b("callTool","chatgpt");return await u(s,c??{})},sendPrompt(s){let c=n();(c?.sendFollowUpMessage??c?.sendFollowupMessage)?.({prompt:s});},openLink(s){let c=n()?.openExternal;c?c({href:s}):t.open(s,"_blank","noopener,noreferrer");},resize(){},capabilities(){let s=n();return {pull:typeof s?.callTool=="function",sendPrompt:typeof s?.sendFollowUpMessage=="function"||typeof s?.sendFollowupMessage=="function",openLink:true}},start(){t.addEventListener(S,m,{passive:true}),i=n()?.toolOutput??i;},destroy(){f||(f=true,t.removeEventListener(S,m),a.clear(),p.clear());}}}function O(t,r){if(!t)return null;let i=t.getElementById(r)?.textContent;if(!i)return null;try{return JSON.parse(i)??null}catch{return null}}function L(t){if(t==null||typeof t!="object")return null;let r=t,n=r.renderData,i=n!=null&&typeof n=="object"?n:r;return i.toolOutput!=null?i.toolOutput:i.structuredContent!=null?i.structuredContent:i}function te(t,r){let n=r.dataElementId??A,i=null,l={mode:C(t),tokens:{}},a=new Set,p=false,f=null,m=s=>{if(p)return;let c=h(s.matches?"dark":"light",l.mode);if(c!==l.mode){l={mode:c,tokens:{}};for(let u of a)u(l);}};return {host:"generic",getData:()=>i,onData(){return ()=>{}},getTheme:()=>l,onTheme(s){return a.add(s),()=>a.delete(s)},async callTool(s){throw new b("callTool","generic")},sendPrompt(){},openLink(s){t.open(s,"_blank","noopener,noreferrer");},resize(){},capabilities(){return {pull:false,sendPrompt:false,openLink:true}},start(){i=O(t.document,n);try{f=t.matchMedia?.("(prefers-color-scheme: dark)")??null,f?.addEventListener?.("change",m);}catch{f=null;}},destroy(){p||(p=true,f?.removeEventListener?.("change",m),f=null,a.clear());}}}var fe=3e4;function ne(t,r){let n=r.dataElementId??A,i=r.autoResize!==false,l=null,a={mode:C(t),tokens:{}},p=new Set,f=new Set,m=false,s=false,c=1,u=new Map,P=-1,v=null,k=null,U=()=>t.parent??t;function I(e){U().postMessage(e,"*");}function _(e){s||I(e);}function x(e,o){I({jsonrpc:"2.0",method:e,params:o??{}});}function w(e,o){let d=c++;return new Promise((g,y)=>{let T=setTimeout(()=>{u.delete(d),y(new Error(`"${e}" timed out`));},fe);u.set(d,{resolve:g,reject:y,timer:T}),I({jsonrpc:"2.0",id:d,method:e,params:o??{}});})}function D(e){if(e!=null){l=e;for(let o of p)o(e);}}function M(e){if(!e||typeof e!="object")return;let{mode:o,tokens:d}=a,g=false;if(e.theme!=null){let T=h(e.theme,o);T!==o&&(o=T,g=true);}let y=e.styles;if(y?.variables&&typeof y.variables=="object"&&(d={...d,...y.variables},g=true),g){a={mode:o,tokens:d};for(let T of f)T(a);}}function E(e){if(m)return;let o=typeof e=="number"?e:Math.ceil(t.document.body.scrollHeight);o!==P&&(P=o,x(B,{height:o}),_({type:G,payload:{height:o}}));}function se(e){let o=Number(e.id),d=u.get(o);if(d)if(u.delete(o),clearTimeout(d.timer),e.error!=null){let g=e.error;d.reject(new Error(g.message??"request failed"));}else d.resolve(e.result);}function ie(e,o){if(e===Y){let d=o.structuredContent;D(d??L(o));}else e===V&&M(o);}function ae(e){e.method===X&&I({jsonrpc:"2.0",id:e.id,result:{}});}function le(e){if(e.type===j||e.type==="renderData"){let{theme:o,...d}=e.payload??{};o!=null&&M({theme:o}),Object.keys(d).length>0&&D(L(d));}}let H=e=>{if(m||e.source&&e.source!==U())return;let o=e.data;if(!(!o||typeof o!="object")){if(o.jsonrpc!=="2.0"){le(o);return}o.id!=null&&("result"in o||"error"in o)?se(o):typeof o.method=="string"&&(o.id!=null?ae(o):ie(o.method,o.params??{}));}};function ce(){k=()=>E(),t.addEventListener("resize",k),typeof t.ResizeObserver<"u"&&(v=new t.ResizeObserver(()=>E()),v.observe(t.document.body));}return {host:"claude",getData:()=>l,onData(e){return p.add(e),()=>p.delete(e)},getTheme:()=>a,onTheme(e){return f.add(e),()=>f.delete(e)},async callTool(e,o){return await w(J,{name:e,arguments:o??{}})},sendPrompt(e){w($,{role:"user",content:[{type:"text",text:e}]}).catch(()=>{}),_({type:W,payload:{prompt:e}});},openLink(e){w(Q,{url:e}).catch(()=>{}),_({type:K,payload:{url:e}});},resize(e){E(e);},capabilities(){return {pull:true,sendPrompt:true,openLink:true}},start(){t.addEventListener("message",H),l=O(t.document,n),i&&ce(),I({type:z}),w(Z,{appInfo:{name:r.name??"synapse-ui",version:r.version??"0.0.0"},appCapabilities:{availableDisplayModes:["inline"]},protocolVersion:F}).then(e=>{m||(s=true,M(e?.hostContext),x(q,{}),P=-1,E());}).catch(()=>{}),E();},destroy(){if(!m){m=true,t.removeEventListener("message",H),k&&t.removeEventListener("resize",k),k=null,v?.disconnect(),v=null;for(let e of u.values())clearTimeout(e.timer),e.reject(new Error("adapter destroyed"));u.clear(),p.clear(),f.clear();}}}}function me(t){if(t.openai!=null)return "chatgpt";try{if(t.parent!=null&&t.parent!==t)return "claude"}catch{return "claude"}return "generic"}function ge(t,r,n){switch(t){case "chatgpt":return ee(r);case "claude":case "nimblebrain":return ne(r,n);default:return te(r,n)}}function oe(t,r){let n=r.host??me(t);return ge(n,t,r)}function re(t={}){let r=t.window??globalThis,n=oe(r,t);R(n.getTheme());let i=n.onTheme(R);n.start();let l=false;return {data:()=>n.getData(),onData:a=>n.onData(a),theme:()=>n.getTheme(),onTheme:a=>n.onTheme(a),callTool:(a,p)=>n.callTool(a,p),sendPrompt:a=>n.sendPrompt(a),openLink:a=>n.openLink(a),resize:a=>n.resize(a),capabilities:()=>n.capabilities(),host:()=>n.host,destroy(){l||(l=true,i(),n.destroy());}}}globalThis.SynapseUI={connect:re};})();
File without changes
@@ -0,0 +1,300 @@
1
+ """SynapseUI — the server (Python) half of the Synapse cross-host UI framework.
2
+
3
+ One declaration wires a self-contained HTML component into every host bridge a
4
+ Synapse app can render in, replacing the hand-rolled per-app shim:
5
+
6
+ - **register** the component as two data-free ``ui://`` resources (SDK inlined):
7
+ the ChatGPT skybridge MIME (``text/html+skybridge``) and the MCP Apps standard
8
+ MIME (``text/html;profile=mcp-app``, Claude Desktop et al.) — so each host reads
9
+ the template and feeds it the tool's ``structuredContent``.
10
+ - **tool_meta / result_meta** emit the `_meta` a host binds an output template
11
+ with (``openai/outputTemplate`` etc.).
12
+ - **bind** installs the ``CallToolResult`` post-process that, for one tool, appends
13
+ the mcp-ui embedded resource (``text/html``, dossier baked into a ``<script>``)
14
+ and mirrors the result ``_meta`` — so Claude / mcp-ui render with no round-trip.
15
+
16
+ The client SDK (`window.SynapseUI`) is inlined into the served + embedded HTML so
17
+ the component is fully self-contained (no CDN, CSP-safe). Plain MCP clients ignore
18
+ the UI pieces and still read ``structuredContent``, so degradation is graceful.
19
+
20
+ The payload is escaped for `<script>` embedding (the XSS defense) in one place
21
+ here, framework-owned and on by default.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from collections.abc import Callable
28
+ from importlib import resources
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from mcp import types
32
+
33
+ if TYPE_CHECKING:
34
+ from mcp.server.fastmcp import FastMCP
35
+
36
+ __all__ = ["SynapseUI"]
37
+
38
+ # ChatGPT requires this exact MIME to render an Apps SDK widget template.
39
+ SKYBRIDGE_MIME = "text/html+skybridge"
40
+ # mcp-ui renders a ui:// resource whose content is raw HTML as text/html.
41
+ MCPUI_MIME = "text/html"
42
+ # MCP Apps standard (SEP-1865): a host mounts the component in an iframe only when
43
+ # the resource is served under this exact MIME (Claude Desktop and other MCP Apps
44
+ # hosts). No space after the semicolon — the string is matched verbatim.
45
+ MCPAPP_MIME = "text/html;profile=mcp-app"
46
+
47
+ # The client reads pushed data from this element by id (mcp-ui / SSR path). Keep
48
+ # in lockstep with the SDK's SYNAPSE_DATA_ELEMENT_ID.
49
+ DEFAULT_DATA_ELEMENT_ID = "synapse-ui-data"
50
+
51
+ # Markers the template carries; substituted at render time.
52
+ # Server↔client template placeholders — the SDK's test fixtures embed the same
53
+ # literals; keep in lockstep (like DEFAULT_DATA_ELEMENT_ID above).
54
+ DATA_MARKER = "/*__SYNAPSE_DATA__*/" # inside the JSON <script>; unreplaced → client reads null
55
+ SDK_MARKER = "<!--__SYNAPSE_SDK__-->" # replaced with the inlined client SDK <script>
56
+
57
+ # The bundled client SDK IIFE (`window.SynapseUI`). Vendored from the JS build
58
+ # (`dist/synapse-ui.iife.global.js`) so the server and client ship together.
59
+ _SDK_ASSET = "synapse-ui.iife.js"
60
+
61
+
62
+ def _load_bundled_sdk() -> str:
63
+ # `__package__ or __name__` is always this package (never None for an imported
64
+ # submodule) and carries no hardcoded name to update on a rename.
65
+ return (resources.files(__package__ or __name__) / "_assets" / _SDK_ASSET).read_text(
66
+ encoding="utf-8"
67
+ )
68
+
69
+
70
+ class SynapseUI:
71
+ """A cross-host `ui://` component declared once and wired into every bridge.
72
+
73
+ Args:
74
+ uri: The single ``ui://`` resource URI both hosts point at.
75
+ template: The data-free component HTML. Should carry {@link SDK_MARKER}
76
+ (where the client SDK is inlined) and a JSON ``<script>`` holding
77
+ {@link DATA_MARKER} with ``id`` = ``data_element_id``.
78
+ preferred_size: mcp-ui preferred frame size, emitted on the embedded
79
+ resource as ``mcpui.dev/ui-preferred-frame-size``.
80
+ data_element_id: ``id`` of the JSON ``<script>`` the client reads.
81
+ inline_sdk: Inline the bundled client SDK into the HTML (default). Set
82
+ ``False`` if the template already carries the SDK.
83
+ sdk_source: Override the inlined SDK source (defaults to the bundled IIFE).
84
+ domain: Unique HTTPS origin for the hosted component (``openai/widgetDomain``
85
+ / ``ui.domain``). Required to submit an Apps SDK app; ChatGPT renders the
86
+ component under ``<hash>.web-sandbox.oaiusercontent.com`` keyed by it.
87
+ connect_domains: Origins the component may reach via fetch/XHR (widget CSP
88
+ ``connect_domains``). Empty for a self-contained component.
89
+ resource_domains: Origins the component may load static assets from (widget
90
+ CSP ``resource_domains``). Empty for a self-contained component.
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ *,
96
+ uri: str,
97
+ template: str,
98
+ preferred_size: tuple[str, str] = ("100%", "auto"),
99
+ data_element_id: str = DEFAULT_DATA_ELEMENT_ID,
100
+ inline_sdk: bool = True,
101
+ sdk_source: str | None = None,
102
+ domain: str | None = None,
103
+ connect_domains: list[str] | None = None,
104
+ resource_domains: list[str] | None = None,
105
+ ) -> None:
106
+ self.uri = uri
107
+ # The MCP Apps standard resource is a sibling URI: a resource carries a
108
+ # single MIME, and Claude (text/html;profile=mcp-app) and ChatGPT
109
+ # (text/html+skybridge) require different ones — so self.uri stays the
110
+ # skybridge resource and the standard resource lives alongside it.
111
+ self.mcp_app_uri = f"{uri}-mcp-app"
112
+ self.data_element_id = data_element_id
113
+ self.preferred_size = preferred_size
114
+ # Widget CSP + a unique hosted-component origin: required to submit an
115
+ # Apps SDK app. A self-contained component (SDK inlined, no fetch/assets)
116
+ # takes empty allowlists — the most restrictive, accurate policy.
117
+ self.domain = domain
118
+ self.connect_domains = connect_domains or []
119
+ self.resource_domains = resource_domains or []
120
+ self._bound: set[str] = set()
121
+ self._template = self._inline_sdk(template, sdk_source) if inline_sdk else template
122
+
123
+ # -- HTML -------------------------------------------------------------
124
+
125
+ @staticmethod
126
+ def _inline_sdk(template: str, sdk_source: str | None) -> str:
127
+ sdk = sdk_source if sdk_source is not None else _load_bundled_sdk()
128
+ script = f"<script>{sdk}</script>"
129
+ if SDK_MARKER in template:
130
+ return template.replace(SDK_MARKER, script, 1)
131
+ # Fallback: inject before </body> (or </html>) so the component still loads.
132
+ for close in ("</body>", "</html>"):
133
+ if close in template:
134
+ return template.replace(close, script + close, 1)
135
+ return template + script
136
+
137
+ def template_html(self) -> str:
138
+ """Data-free HTML (served resource / ChatGPT): SDK inlined, data marker intact."""
139
+ return self._template
140
+
141
+ @staticmethod
142
+ def _safe_json(data: Any) -> str:
143
+ """JSON safe to embed inside a ``<script>`` element.
144
+
145
+ Escapes ``<``/``>``/``&`` and the U+2028/U+2029 separators so a value in
146
+ the payload can neither close the script tag (``</script>``) nor break the
147
+ surrounding HTML/JS — the JSON stays valid and inert. This is the XSS
148
+ defense, framework-owned and on by default.
149
+ """
150
+ raw = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
151
+ return (
152
+ raw.replace("<", "\\u003c")
153
+ .replace(">", "\\u003e")
154
+ .replace("&", "\\u0026")
155
+ .replace("
", "\\u2028")
156
+ .replace("
", "\\u2029")
157
+ )
158
+
159
+ def render_html(self, data: Any) -> str:
160
+ """HTML with ``data`` baked into the JSON ``<script>`` (mcp-ui embedded copy)."""
161
+ return self._template.replace(DATA_MARKER, self._safe_json(data), 1)
162
+
163
+ # -- MCP wiring -------------------------------------------------------
164
+
165
+ def embedded_resource(self, data: Any) -> types.EmbeddedResource:
166
+ """The mcp-ui content block: a ``ui://`` resource carrying ``data`` inline."""
167
+ return types.EmbeddedResource(
168
+ type="resource",
169
+ resource=types.TextResourceContents(
170
+ uri=self.uri,
171
+ mimeType=MCPUI_MIME,
172
+ text=self.render_html(data),
173
+ ),
174
+ annotations=types.Annotations(audience=["user"]),
175
+ _meta={"mcpui.dev/ui-preferred-frame-size": list(self.preferred_size)},
176
+ )
177
+
178
+ def tool_meta(
179
+ self,
180
+ *,
181
+ invoking: str | None = None,
182
+ invoked: str | None = None,
183
+ widget_accessible: bool = True,
184
+ ) -> dict[str, Any]:
185
+ """`_meta` for the tool descriptor — how a host binds the output template.
186
+
187
+ ChatGPT reads ``openai/outputTemplate``; Claude and other MCP Apps hosts
188
+ read the nested ``ui.resourceUri`` (SEP-1865). The flat
189
+ ``_meta["ui/resourceUri"]`` form is deprecated and slated for removal
190
+ before GA, so it is not emitted.
191
+ """
192
+ meta: dict[str, Any] = {
193
+ "openai/outputTemplate": self.uri,
194
+ "openai/widgetAccessible": widget_accessible,
195
+ "ui": {"resourceUri": self.mcp_app_uri},
196
+ }
197
+ if invoking is not None:
198
+ meta["openai/toolInvocation/invoking"] = invoking
199
+ if invoked is not None:
200
+ meta["openai/toolInvocation/invoked"] = invoked
201
+ return meta
202
+
203
+ def result_meta(self) -> dict[str, Any]:
204
+ """`_meta` for the tool *result* — mirrors the template pointer per call."""
205
+ return {"openai/outputTemplate": self.uri}
206
+
207
+ def register(self, mcp: FastMCP, *, meta: dict[str, Any] | None = None) -> None:
208
+ """Register both host-facing ``ui://`` resources (data-free, SDK inlined).
209
+
210
+ The same component is served twice because a resource carries one MIME and
211
+ the hosts disagree: ``self.uri`` under ``text/html+skybridge`` for ChatGPT,
212
+ and ``self.mcp_app_uri`` under ``text/html;profile=mcp-app`` for Claude and
213
+ other MCP Apps hosts. Both point at the same inlined HTML.
214
+ """
215
+ html = self.template_html()
216
+
217
+ # ChatGPT (skybridge): the flat `openai/*` dialect. CSP + a unique domain
218
+ # are required to submit the app; without them ChatGPT's dev view flags the
219
+ # template as submission-incomplete.
220
+ resource_meta: dict[str, Any] = {
221
+ "openai/widgetPrefersBorder": True,
222
+ "openai/widgetCSP": {
223
+ "connect_domains": self.connect_domains,
224
+ "resource_domains": self.resource_domains,
225
+ },
226
+ }
227
+ if self.domain is not None:
228
+ resource_meta["openai/widgetDomain"] = self.domain
229
+ resource_meta.update(meta or {})
230
+
231
+ @mcp.resource(self.uri, mime_type=SKYBRIDGE_MIME, meta=resource_meta)
232
+ def _synapse_ui_resource() -> str:
233
+ return html
234
+
235
+ # MCP Apps standard (Claude et al.): the nested `ui.*` dialect, camelCase.
236
+ ui_meta: dict[str, Any] = {
237
+ "prefersBorder": True,
238
+ "csp": {
239
+ "connectDomains": self.connect_domains,
240
+ "resourceDomains": self.resource_domains,
241
+ },
242
+ }
243
+ if self.domain is not None:
244
+ ui_meta["domain"] = self.domain
245
+
246
+ @mcp.resource(
247
+ self.mcp_app_uri,
248
+ mime_type=MCPAPP_MIME,
249
+ meta={"ui": ui_meta},
250
+ )
251
+ def _synapse_ui_mcp_app_resource() -> str:
252
+ return html
253
+
254
+ def bind(
255
+ self,
256
+ mcp: FastMCP,
257
+ *,
258
+ tool: str,
259
+ should_render: Callable[[Any], bool] | None = None,
260
+ ) -> None:
261
+ """Install the ``CallToolResult`` post-process that renders `tool`'s output.
262
+
263
+ For a successful, non-error result of ``tool`` that carries
264
+ ``structuredContent`` (and passes ``should_render``), appends the mcp-ui
265
+ embedded resource and mirrors the ChatGPT ``_meta``. Plain clients ignore
266
+ both and still read the structured JSON.
267
+
268
+ Quarantine note: this wraps FastMCP's ``CallToolRequest`` handler — a leak
269
+ into FastMCP internals kept in this one place so no app pokes them.
270
+
271
+ # TODO: upstream a real FastMCP result-transform hook and drop this patch.
272
+ """
273
+ if tool in self._bound: # idempotent: don't chain a second wrapper for the same tool
274
+ return
275
+ self._bound.add(tool)
276
+ predicate = should_render if should_render is not None else (lambda data: bool(data))
277
+ prev = mcp._mcp_server.request_handlers[types.CallToolRequest]
278
+
279
+ async def _handler(req: types.CallToolRequest) -> types.ServerResult:
280
+ result = await prev(req)
281
+ if req.params.name == tool:
282
+ return self._attach(result, predicate)
283
+ return result
284
+
285
+ mcp._mcp_server.request_handlers[types.CallToolRequest] = _handler
286
+
287
+ def _attach(
288
+ self,
289
+ result: types.ServerResult,
290
+ predicate: Callable[[Any], bool],
291
+ ) -> types.ServerResult:
292
+ root = result.root
293
+ if not isinstance(root, types.CallToolResult) or root.isError:
294
+ return result
295
+ data = root.structuredContent
296
+ if not data or not predicate(data):
297
+ return result
298
+ root.content.append(self.embedded_resource(data))
299
+ root.meta = {**(root.meta or {}), **self.result_meta()}
300
+ return result
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: nimblebrain-synapse
3
+ Version: 0.1.0
4
+ Summary: Server half of the Synapse cross-host UI framework: register one self-contained ui:// component from a FastMCP server and render it in ChatGPT (OpenAI Apps SDK), Claude (MCP Apps), and the NimbleBrain runtime.
5
+ Project-URL: Homepage, https://github.com/NimbleBrainInc/synapse
6
+ Project-URL: Repository, https://github.com/NimbleBrainInc/synapse
7
+ Project-URL: Issues, https://github.com/NimbleBrainInc/synapse/issues
8
+ Project-URL: Changelog, https://github.com/NimbleBrainInc/synapse/blob/main/python/CHANGELOG.md
9
+ Author: NimbleBrain Inc.
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: fastmcp,mcp,mcp-apps,openai-apps,synapse,ui,widget
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: mcp>=1.26.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # nimblebrain-synapse (Python)
26
+
27
+ The **server half** of the Synapse cross-host UI framework. Pairs with the
28
+ `@nimblebrain/synapse` client (`connectUI` / `window.SynapseUI`).
29
+
30
+ ```bash
31
+ pip install nimblebrain-synapse # or: uv add nimblebrain-synapse
32
+ ```
33
+
34
+ One `SynapseUI` declaration wires a self-contained HTML component into every host
35
+ bridge a Synapse app renders in — **ChatGPT** (OpenAI Apps SDK), **Claude** (MCP
36
+ Apps), and the **NimbleBrain** runtime — from a FastMCP server, replacing the
37
+ per-app hand-rolled shim.
38
+
39
+ ```python
40
+ from mcp.server.fastmcp import FastMCP
41
+ from nimblebrain_synapse import SynapseUI
42
+
43
+ mcp = FastMCP("bassethound")
44
+
45
+ report_ui = SynapseUI(
46
+ uri="ui://bassethound/report",
47
+ template=load_template(), # data-free HTML (carries the SDK + data markers)
48
+ preferred_size=("100%", "auto"),
49
+ )
50
+ report_ui.register(mcp) # skybridge ui:// resource, SDK inlined
51
+
52
+ @mcp.tool(meta=report_ui.tool_meta(invoking="Picking up the scent…", invoked="Dossier ready"))
53
+ async def analyze_domain(domain: str) -> Dossier:
54
+ ...
55
+
56
+ report_ui.bind(mcp, tool="analyze_domain", should_render=lambda d: "domain" in d)
57
+ ```
58
+
59
+ `register` serves the ChatGPT-facing skybridge resource (data-free). `bind`
60
+ post-processes the `CallToolResult` for one tool: appends the mcp-ui embedded
61
+ `ui://` resource (dossier baked into a `<script>`) and mirrors the ChatGPT
62
+ `_meta`. Plain MCP clients ignore both and still read `structuredContent`.
63
+
64
+ ## Template contract
65
+
66
+ The `template` is data-free HTML that carries two markers:
67
+
68
+ - `<!--__SYNAPSE_SDK__-->` — replaced with the inlined client SDK `<script>`.
69
+ - `<script type="application/json" id="synapse-ui-data">/*__SYNAPSE_DATA__*/</script>`
70
+ — the data slot; `render_html(data)` substitutes the escaped payload here (the
71
+ served copy leaves the marker, so the client reads `null` and falls back to the
72
+ host's push).
73
+
74
+ `SynapseUI._safe_json` escapes the payload for `<script>` embedding (the XSS
75
+ defense) — framework-owned and on by default.
76
+
77
+ ## Interface debt
78
+
79
+ `bind` wraps FastMCP's `CallToolRequest` handler — a leak into FastMCP internals,
80
+ quarantined in this one place. See the `# TODO: upstream a real FastMCP
81
+ result-transform hook` note in `server.py`.
82
+
83
+ ## Client SDK asset
84
+
85
+ `nimblebrain_synapse/_assets/synapse-ui.iife.js` is the vendored client IIFE
86
+ (`window.SynapseUI`), regenerated from the JS build
87
+ (`dist/synapse-ui.iife.global.js`) and inlined at register time so a component
88
+ is fully self-contained (CSP-safe, no CDN). CI fails on drift from the build.
89
+ `nimblebrain_synapse.__client_version__` records which `@nimblebrain/synapse` release the
90
+ bundled IIFE was built from.
91
+
92
+ ## Versioning & compatibility
93
+
94
+ `nimblebrain-synapse` (PyPI) versions **independently** of `@nimblebrain/synapse` (npm).
95
+ They change for different reasons at different cadences — the server descriptor is
96
+ thin and stable; the JS client evolves with host adapters and theming — so they do
97
+ not share a version number. The exact client build a given release bundles is
98
+ recorded in `nimblebrain_synapse.__client_version__` (and, per release, in the
99
+ [CHANGELOG](https://github.com/NimbleBrainInc/synapse/blob/main/python/CHANGELOG.md));
100
+ CI keeps it equal to the sibling `package.json` at HEAD.
101
+
102
+ What both halves share is the **wire protocol** — the `ui://` resource MIMEs, the
103
+ `_meta` dialects, and the data-element contract:
104
+
105
+ - ext-apps `2026-01-26`
106
+ - MCP Apps (SEP-1865)
107
+ - OpenAI Apps SDK
108
+
109
+ Releases publish on a `nimblebrain-synapse-v*` tag (distinct from the npm `v*` tags).
@@ -0,0 +1,8 @@
1
+ nimblebrain_synapse/__init__.py,sha256=sbR73hDwy-YXu8Ql-PxTV08hjy7crBKa5RcaD57oJaw,1279
2
+ nimblebrain_synapse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ nimblebrain_synapse/server.py,sha256=eKs93-xY9QzyDNvKOaQKY0NZRpDrI9oXHfhLLyUzmmw,13259
4
+ nimblebrain_synapse/_assets/synapse-ui.iife.js,sha256=5GSX3Lq69y1Q2rd6soYke760Z6IAgM0h7N7nhegXefc,8336
5
+ nimblebrain_synapse-0.1.0.dist-info/METADATA,sha256=RLZcrhlOeu7nqKFcQwO5I0pft2mTEyjoWF1P0gdcazg,4769
6
+ nimblebrain_synapse-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ nimblebrain_synapse-0.1.0.dist-info/licenses/LICENSE,sha256=RTSjbg00L14JSy_hagMUQk7gs0-BolAfQKLBMDFF4XE,1074
8
+ nimblebrain_synapse-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NimbleBrain, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.