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.
- nimblebrain_synapse-0.1.0/.gitignore +8 -0
- nimblebrain_synapse-0.1.0/CHANGELOG.md +22 -0
- nimblebrain_synapse-0.1.0/LICENSE +21 -0
- nimblebrain_synapse-0.1.0/PKG-INFO +109 -0
- nimblebrain_synapse-0.1.0/README.md +85 -0
- nimblebrain_synapse-0.1.0/nimblebrain_synapse/__init__.py +34 -0
- nimblebrain_synapse-0.1.0/nimblebrain_synapse/_assets/synapse-ui.iife.js +1 -0
- nimblebrain_synapse-0.1.0/nimblebrain_synapse/py.typed +0 -0
- nimblebrain_synapse-0.1.0/nimblebrain_synapse/server.py +300 -0
- nimblebrain_synapse-0.1.0/pyproject.toml +56 -0
- nimblebrain_synapse-0.1.0/tests/test_synapse_ui.py +297 -0
|
@@ -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
|