nimblebrain-synapse 0.1.0__tar.gz

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,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ *.egg-info/
8
+ uv.lock
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to `nimblebrain-synapse` (the Python package) are documented here.
4
+ It versions **independently** of the `@nimblebrain/synapse` npm package — the two
5
+ meet only on the wire protocol, not on a shared version number.
6
+
7
+ This project adheres to [Semantic Versioning](https://semver.org/).
8
+
9
+ ## [0.1.0]
10
+
11
+ First published release to PyPI. The `SynapseUI` server descriptor previously
12
+ shipped only as source in this repo and vendored copies; it is now an installable
13
+ package so bundles depend on one source of truth instead of copying it.
14
+
15
+ ### Added
16
+
17
+ - `SynapseUI` server descriptor: dual-MIME `ui://` registration (ChatGPT
18
+ `text/html+skybridge` + MCP Apps `text/html;profile=mcp-app`), tool/result
19
+ `_meta` in both dialects, widget CSP + domain, `<script>`-safe data embedding
20
+ (the XSS defense), and the quarantined `CallToolResult` render injection.
21
+ - Bundled client IIFE (`window.SynapseUI`) from `@nimblebrain/synapse` 0.12.0,
22
+ inlined so a component stays self-contained (CSP-safe, no CDN).
@@ -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.
@@ -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,85 @@
1
+ # nimblebrain-synapse (Python)
2
+
3
+ The **server half** of the Synapse cross-host UI framework. Pairs with the
4
+ `@nimblebrain/synapse` client (`connectUI` / `window.SynapseUI`).
5
+
6
+ ```bash
7
+ pip install nimblebrain-synapse # or: uv add nimblebrain-synapse
8
+ ```
9
+
10
+ One `SynapseUI` declaration wires a self-contained HTML component into every host
11
+ bridge a Synapse app renders in — **ChatGPT** (OpenAI Apps SDK), **Claude** (MCP
12
+ Apps), and the **NimbleBrain** runtime — from a FastMCP server, replacing the
13
+ per-app hand-rolled shim.
14
+
15
+ ```python
16
+ from mcp.server.fastmcp import FastMCP
17
+ from nimblebrain_synapse import SynapseUI
18
+
19
+ mcp = FastMCP("bassethound")
20
+
21
+ report_ui = SynapseUI(
22
+ uri="ui://bassethound/report",
23
+ template=load_template(), # data-free HTML (carries the SDK + data markers)
24
+ preferred_size=("100%", "auto"),
25
+ )
26
+ report_ui.register(mcp) # skybridge ui:// resource, SDK inlined
27
+
28
+ @mcp.tool(meta=report_ui.tool_meta(invoking="Picking up the scent…", invoked="Dossier ready"))
29
+ async def analyze_domain(domain: str) -> Dossier:
30
+ ...
31
+
32
+ report_ui.bind(mcp, tool="analyze_domain", should_render=lambda d: "domain" in d)
33
+ ```
34
+
35
+ `register` serves the ChatGPT-facing skybridge resource (data-free). `bind`
36
+ post-processes the `CallToolResult` for one tool: appends the mcp-ui embedded
37
+ `ui://` resource (dossier baked into a `<script>`) and mirrors the ChatGPT
38
+ `_meta`. Plain MCP clients ignore both and still read `structuredContent`.
39
+
40
+ ## Template contract
41
+
42
+ The `template` is data-free HTML that carries two markers:
43
+
44
+ - `<!--__SYNAPSE_SDK__-->` — replaced with the inlined client SDK `<script>`.
45
+ - `<script type="application/json" id="synapse-ui-data">/*__SYNAPSE_DATA__*/</script>`
46
+ — the data slot; `render_html(data)` substitutes the escaped payload here (the
47
+ served copy leaves the marker, so the client reads `null` and falls back to the
48
+ host's push).
49
+
50
+ `SynapseUI._safe_json` escapes the payload for `<script>` embedding (the XSS
51
+ defense) — framework-owned and on by default.
52
+
53
+ ## Interface debt
54
+
55
+ `bind` wraps FastMCP's `CallToolRequest` handler — a leak into FastMCP internals,
56
+ quarantined in this one place. See the `# TODO: upstream a real FastMCP
57
+ result-transform hook` note in `server.py`.
58
+
59
+ ## Client SDK asset
60
+
61
+ `nimblebrain_synapse/_assets/synapse-ui.iife.js` is the vendored client IIFE
62
+ (`window.SynapseUI`), regenerated from the JS build
63
+ (`dist/synapse-ui.iife.global.js`) and inlined at register time so a component
64
+ is fully self-contained (CSP-safe, no CDN). CI fails on drift from the build.
65
+ `nimblebrain_synapse.__client_version__` records which `@nimblebrain/synapse` release the
66
+ bundled IIFE was built from.
67
+
68
+ ## Versioning & compatibility
69
+
70
+ `nimblebrain-synapse` (PyPI) versions **independently** of `@nimblebrain/synapse` (npm).
71
+ They change for different reasons at different cadences — the server descriptor is
72
+ thin and stable; the JS client evolves with host adapters and theming — so they do
73
+ not share a version number. The exact client build a given release bundles is
74
+ recorded in `nimblebrain_synapse.__client_version__` (and, per release, in the
75
+ [CHANGELOG](https://github.com/NimbleBrainInc/synapse/blob/main/python/CHANGELOG.md));
76
+ CI keeps it equal to the sibling `package.json` at HEAD.
77
+
78
+ What both halves share is the **wire protocol** — the `ui://` resource MIMEs, the
79
+ `_meta` dialects, and the data-element contract:
80
+
81
+ - ext-apps `2026-01-26`
82
+ - MCP Apps (SEP-1865)
83
+ - OpenAI Apps SDK
84
+
85
+ Releases publish on a `nimblebrain-synapse-v*` tag (distinct from the npm `v*` tags).
@@ -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,56 @@
1
+ [project]
2
+ name = "nimblebrain-synapse"
3
+ version = "0.1.0"
4
+ description = "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
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "NimbleBrain Inc." }]
10
+ keywords = ["mcp", "fastmcp", "mcp-apps", "openai-apps", "synapse", "ui", "widget"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
20
+ ]
21
+ dependencies = [
22
+ # register() passes meta= to @mcp.resource(); that kwarg landed in mcp 1.26.0.
23
+ "mcp>=1.26.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/NimbleBrainInc/synapse"
28
+ Repository = "https://github.com/NimbleBrainInc/synapse"
29
+ Issues = "https://github.com/NimbleBrainInc/synapse/issues"
30
+ Changelog = "https://github.com/NimbleBrainInc/synapse/blob/main/python/CHANGELOG.md"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "ruff==0.15.21",
35
+ "ty==0.0.61",
36
+ "pytest>=8.3",
37
+ "pytest-asyncio>=0.25",
38
+ ]
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["nimblebrain_synapse"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py311"
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "I", "UP", "B", "SIM"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ asyncio_mode = "auto"
@@ -0,0 +1,297 @@
1
+ """Tests for the SynapseUI server helper — no network.
2
+
3
+ Covers HTML preparation (SDK inlining + data baking), the `<script>`-safe escape
4
+ (the XSS defense), the mcp-ui embedded resource, the `_meta` emitters, and the
5
+ FastMCP wiring (dual-MIME registration + the bound CallToolResult injection).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from mcp import types
11
+ from mcp.server.fastmcp import FastMCP
12
+ from pydantic import BaseModel
13
+
14
+ from nimblebrain_synapse import SynapseUI
15
+ from nimblebrain_synapse.server import DATA_MARKER, SDK_MARKER
16
+
17
+ UI_URI = "ui://test/report"
18
+
19
+
20
+ class _IntegrationReport(BaseModel):
21
+ """Module-level model so FastMCP can resolve the tool's return annotation
22
+ and populate structuredContent in the real-FastMCP integration test."""
23
+
24
+ domain: str
25
+ company: dict
26
+
27
+
28
+ TEMPLATE = f"""<!DOCTYPE html>
29
+ <html><head><title>t</title></head><body>
30
+ <script type="application/json" id="synapse-ui-data">{DATA_MARKER}</script>
31
+ <div id="app"></div>
32
+ {SDK_MARKER}
33
+ <script>var s = window.SynapseUI.connect(); s.onData(function(){{}});</script>
34
+ </body></html>"""
35
+
36
+
37
+ def _ui() -> SynapseUI:
38
+ return SynapseUI(uri=UI_URI, template=TEMPLATE)
39
+
40
+
41
+ def _dossier() -> dict:
42
+ return {"domain": "example.com", "company": {"name": "Example Co"}}
43
+
44
+
45
+ def test_template_inlines_sdk_and_keeps_data_marker():
46
+ html = _ui().template_html()
47
+ assert SDK_MARKER not in html # marker replaced
48
+ assert "window.SynapseUI" in html # SDK inlined
49
+ assert DATA_MARKER in html # served template stays data-free
50
+
51
+
52
+ def test_render_bakes_data_and_removes_marker():
53
+ html = _ui().render_html(_dossier())
54
+ assert DATA_MARKER not in html
55
+ assert '"example.com"' in html
56
+ assert "window.SynapseUI" in html # SDK still present in embedded copy
57
+
58
+
59
+ def test_safe_json_escapes_script_breakout_but_keeps_spaces():
60
+ out = SynapseUI._safe_json({"x": "</script><img onerror=1>", "y": "two words"})
61
+ assert "</script>" not in out
62
+ assert "\\u003c" in out and "\\u003e" in out
63
+ assert "two words" in out # ordinary spaces preserved
64
+
65
+
66
+ def test_xss_probe_hostile_company_name_stays_inert():
67
+ """A hostile value in the payload cannot close the <script> or inject markup."""
68
+ hostile = "</script><script>alert('xss')</script>"
69
+ html = _ui().render_html({"domain": "evil.test", "company": {"name": hostile}})
70
+ # The raw breakout sequence must not appear in the rendered HTML.
71
+ assert "</script><script>alert" not in html
72
+ # It survives as escaped, inert JSON.
73
+ assert "\\u003c/script\\u003e" in html
74
+
75
+
76
+ def test_embedded_resource_shape():
77
+ res = _ui().embedded_resource(_dossier())
78
+ assert res.type == "resource"
79
+ rc = res.resource
80
+ assert isinstance(rc, types.TextResourceContents)
81
+ assert str(rc.uri) == UI_URI
82
+ assert rc.mimeType == "text/html"
83
+ assert "example.com" in rc.text
84
+ assert res.meta == {"mcpui.dev/ui-preferred-frame-size": ["100%", "auto"]}
85
+
86
+
87
+ def test_tool_and_result_meta():
88
+ ui = _ui()
89
+ tm = ui.tool_meta(invoking="Working…", invoked="Done")
90
+ assert tm["openai/outputTemplate"] == UI_URI
91
+ assert tm["openai/widgetAccessible"] is True
92
+ assert tm["openai/toolInvocation/invoking"] == "Working…"
93
+ assert tm["openai/toolInvocation/invoked"] == "Done"
94
+ # MCP Apps standard: nested resourceUri points at the sibling mcp-app resource.
95
+ assert tm["ui"] == {"resourceUri": f"{UI_URI}-mcp-app"}
96
+ assert ui.result_meta() == {"openai/outputTemplate": UI_URI}
97
+
98
+
99
+ def test_register_installs_both_host_resources():
100
+ mcp = FastMCP("test")
101
+ _ui().register(mcp)
102
+ resources = {str(r.uri): r.mime_type for r in mcp._resource_manager.list_resources()}
103
+ # ChatGPT skybridge (unchanged) + the MCP Apps standard resource for Claude.
104
+ assert resources.get(UI_URI) == "text/html+skybridge"
105
+ assert resources.get(f"{UI_URI}-mcp-app") == "text/html;profile=mcp-app"
106
+
107
+
108
+ class _CapturingMCP:
109
+ """Records the ``meta`` each ``@mcp.resource(...)`` registration carries."""
110
+
111
+ def __init__(self) -> None:
112
+ self.registered: dict[str, dict] = {}
113
+
114
+ def resource(self, uri: str, *, mime_type: str, meta: dict):
115
+ self.registered[uri] = {"mime_type": mime_type, "meta": meta}
116
+ return lambda fn: fn
117
+
118
+
119
+ def test_register_emits_widget_csp_and_domain_in_both_dialects():
120
+ mcp = _CapturingMCP()
121
+ SynapseUI(uri=UI_URI, template=TEMPLATE, domain="https://example.com").register(mcp)
122
+
123
+ # ChatGPT (skybridge) — flat openai/* dialect, snake_case CSP.
124
+ sky = mcp.registered[UI_URI]["meta"]
125
+ assert sky["openai/widgetPrefersBorder"] is True
126
+ assert sky["openai/widgetDomain"] == "https://example.com"
127
+ assert sky["openai/widgetCSP"] == {"connect_domains": [], "resource_domains": []}
128
+
129
+ # MCP Apps standard — nested ui.* dialect, camelCase CSP.
130
+ app = mcp.registered[f"{UI_URI}-mcp-app"]["meta"]["ui"]
131
+ assert app["prefersBorder"] is True
132
+ assert app["domain"] == "https://example.com"
133
+ assert app["csp"] == {"connectDomains": [], "resourceDomains": []}
134
+
135
+
136
+ def test_register_carries_non_empty_allowlists_and_omits_domain_when_unset():
137
+ mcp = _CapturingMCP()
138
+ SynapseUI(
139
+ uri=UI_URI,
140
+ template=TEMPLATE,
141
+ connect_domains=["https://api.example.com"],
142
+ resource_domains=["https://cdn.example.com"],
143
+ ).register(mcp)
144
+
145
+ sky = mcp.registered[UI_URI]["meta"]
146
+ # CSP is always present (a self-contained default); domain only when provided.
147
+ assert sky["openai/widgetCSP"]["connect_domains"] == ["https://api.example.com"]
148
+ assert sky["openai/widgetCSP"]["resource_domains"] == ["https://cdn.example.com"]
149
+ assert "openai/widgetDomain" not in sky
150
+ assert "domain" not in mcp.registered[f"{UI_URI}-mcp-app"]["meta"]["ui"]
151
+
152
+
153
+ def test_bind_injects_embedded_resource_and_result_meta():
154
+ mcp = FastMCP("test")
155
+ ui = _ui()
156
+ ui.bind(mcp, tool="analyze")
157
+
158
+ ctr = types.CallToolResult(
159
+ content=[types.TextContent(type="text", text="{}")],
160
+ structuredContent=_dossier(),
161
+ isError=False,
162
+ )
163
+ # Drive the bound handler by calling the wrapped request handler directly is
164
+ # heavy; instead exercise the attach path the handler delegates to.
165
+ out = ui._attach(types.ServerResult(ctr), lambda d: bool(d)).root
166
+ assert isinstance(out, types.CallToolResult)
167
+ assert out.meta is not None
168
+ assert out.meta["openai/outputTemplate"] == UI_URI
169
+ embedded = [c for c in out.content if isinstance(c, types.EmbeddedResource)]
170
+ assert len(embedded) == 1
171
+ assert "example.com" in embedded[0].resource.text
172
+
173
+
174
+ def test_bind_skips_errors_and_empty_results():
175
+ ui = _ui()
176
+ err = types.ServerResult(types.CallToolResult(content=[], isError=True))
177
+ err_root = err.root
178
+ assert ui._attach(err, lambda d: bool(d)).root is err_root
179
+ assert err_root.meta is None
180
+
181
+ empty = types.ServerResult(
182
+ types.CallToolResult(content=[], structuredContent=None, isError=False)
183
+ )
184
+ out = ui._attach(empty, lambda d: bool(d)).root
185
+ assert out.meta is None
186
+ assert all(not isinstance(c, types.EmbeddedResource) for c in out.content)
187
+
188
+
189
+ def test_bind_respects_should_render_predicate():
190
+ ui = _ui()
191
+ ctr = types.CallToolResult(content=[], structuredContent={"unrelated": True}, isError=False)
192
+ out = ui._attach(types.ServerResult(ctr), lambda d: "domain" in d).root
193
+ # Predicate rejects → no injection.
194
+ assert out.meta is None
195
+ assert all(not isinstance(c, types.EmbeddedResource) for c in out.content)
196
+
197
+
198
+ class _FakeServer:
199
+ """Minimal stand-in for FastMCP's low-level server: just the handler registry."""
200
+
201
+ def __init__(self, prev):
202
+ self.request_handlers = {types.CallToolRequest: prev}
203
+
204
+
205
+ class _FakeMcp:
206
+ def __init__(self, prev):
207
+ self._mcp_server = _FakeServer(prev)
208
+
209
+
210
+ def _call_tool_request(name: str) -> types.CallToolRequest:
211
+ return types.CallToolRequest(
212
+ method="tools/call",
213
+ params=types.CallToolRequestParams(name=name, arguments={}),
214
+ )
215
+
216
+
217
+ async def test_bind_dispatches_by_tool_name():
218
+ """The installed wrapper injects for the bound tool and passes others through."""
219
+ ui = _ui()
220
+
221
+ async def prev(req: types.CallToolRequest) -> types.ServerResult:
222
+ return types.ServerResult(
223
+ types.CallToolResult(
224
+ content=[types.TextContent(type="text", text="{}")],
225
+ structuredContent=_dossier(),
226
+ isError=False,
227
+ )
228
+ )
229
+
230
+ mcp = _FakeMcp(prev)
231
+ ui.bind(mcp, tool="analyze")
232
+ handler = mcp._mcp_server.request_handlers[types.CallToolRequest]
233
+
234
+ # Bound tool → embedded resource + result meta injected.
235
+ hit = (await handler(_call_tool_request("analyze"))).root
236
+ assert hit.meta is not None and hit.meta["openai/outputTemplate"] == UI_URI
237
+ assert any(isinstance(c, types.EmbeddedResource) for c in hit.content)
238
+
239
+ # Other tool → passed through untouched.
240
+ miss = (await handler(_call_tool_request("other"))).root
241
+ assert miss.meta is None
242
+ assert all(not isinstance(c, types.EmbeddedResource) for c in miss.content)
243
+
244
+
245
+ async def test_bind_against_real_fastmcp_drives_installed_handler():
246
+ """Bind against a real FastMCP and drive a real CallToolRequest through the
247
+ installed handler. The _FakeMcp tests can't catch drift in mcp's internal
248
+ request-handler registry — exactly the risk the bind() monkey-patch carries."""
249
+ mcp = FastMCP("test")
250
+
251
+ @mcp.tool()
252
+ def analyze(domain: str) -> _IntegrationReport:
253
+ return _IntegrationReport(domain=domain, company={"name": "Example Co"})
254
+
255
+ ui = _ui()
256
+ ui.register(mcp)
257
+ ui.bind(mcp, tool="analyze")
258
+
259
+ handler = mcp._mcp_server.request_handlers[types.CallToolRequest]
260
+ result = await handler(
261
+ types.CallToolRequest(
262
+ method="tools/call",
263
+ params=types.CallToolRequestParams(name="analyze", arguments={"domain": "example.com"}),
264
+ )
265
+ )
266
+ root = result.root
267
+ assert isinstance(root, types.CallToolResult)
268
+ assert not root.isError
269
+ # FastMCP built structuredContent the real way; the patch appended the UI + meta.
270
+ assert root.meta is not None
271
+ assert root.meta["openai/outputTemplate"] == UI_URI
272
+ embedded = [c for c in root.content if isinstance(c, types.EmbeddedResource)]
273
+ assert len(embedded) == 1
274
+ assert "example.com" in embedded[0].resource.text
275
+
276
+
277
+ async def test_bind_is_idempotent_per_tool():
278
+ """Binding the same tool twice must not chain two wrappers (double-inject)."""
279
+ ui = _ui()
280
+
281
+ async def prev(req: types.CallToolRequest) -> types.ServerResult:
282
+ return types.ServerResult(
283
+ types.CallToolResult(
284
+ content=[types.TextContent(type="text", text="{}")],
285
+ structuredContent=_dossier(),
286
+ isError=False,
287
+ )
288
+ )
289
+
290
+ mcp = _FakeMcp(prev)
291
+ ui.bind(mcp, tool="analyze")
292
+ ui.bind(mcp, tool="analyze") # second bind is a no-op
293
+
294
+ handler = mcp._mcp_server.request_handlers[types.CallToolRequest]
295
+ root = (await handler(_call_tool_request("analyze"))).root
296
+ embedded = [c for c in root.content if isinstance(c, types.EmbeddedResource)]
297
+ assert len(embedded) == 1 # injected exactly once, not twice