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.
- nimblebrain_synapse/__init__.py +34 -0
- nimblebrain_synapse/_assets/synapse-ui.iife.js +1 -0
- nimblebrain_synapse/py.typed +0 -0
- nimblebrain_synapse/server.py +300 -0
- nimblebrain_synapse-0.1.0.dist-info/METADATA +109 -0
- nimblebrain_synapse-0.1.0.dist-info/RECORD +8 -0
- nimblebrain_synapse-0.1.0.dist-info/WHEEL +4 -0
- nimblebrain_synapse-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|