Skip to content

Commit 295ed04

Browse files
docs for iframe / mcp (#160)
* docs for iframe / mcp * escape curly braces
1 parent 9c8225f commit 295ed04

4 files changed

Lines changed: 303 additions & 11 deletions

File tree

content/workspace/developers/json-specs/widgets-json-reference.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,19 @@ A `Widgets.json` table is a configuration structure with any of the named attrib
8181
- **type**
8282
_Type:_ `string`
8383
Sets the default visualization type for the widget.
84-
_Possible values:_ `"chart"`, `"table"`, `"table_ssrm"`, `"markdown"`, `"metric"`, `"note"`, `"multi_file_viewer"`, `"live_grid"`, `"newsfeed"`, `"advanced-chart"`, `"chart-highcharts"`, `"chart-vegalite"`, `"youtube"`
84+
_Possible values:_ `"chart"`, `"table"`, `"table_ssrm"`, `"markdown"`, `"metric"`, `"note"`, `"multi_file_viewer"`, `"live_grid"`, `"newsfeed"`, `"advanced-chart"`, `"chart-highcharts"`, `"chart-vegalite"`, `"youtube"`, `"iframe"`
8585
_Default:_ `"table"`
8686

87+
- **storage**
88+
_Type:_ object
89+
Persisted, widget-specific configuration. Currently used by the [Iframe](../widget-types/iframe) widget to auto-connect an MCP server when the widget mounts.
90+
Contains the following keys:
91+
92+
- **mcpUrl**
93+
_Type:_ `string`
94+
The URL of an MCP server to auto-connect when an Iframe widget mounts. Tools exposed by that server become available to Copilot immediately, with no manual URL entry.
95+
_Example:_ `"http://localhost:7769/mcp"`
96+
8797
- **raw**
8898
_Type:_ `boolean`
8999
ONLY used for Plotly configuration. If true will create a button on the widget to switch between the chart and raw data.
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
---
2+
title: Iframe
3+
sidebar_position: 16
4+
description: Embed an external web app (such as a Streamlit dashboard) as an iframe widget in OpenBB Workspace, with optional sub-widget export via the Iframe Widget Protocol and auto-connected MCP tools.
5+
keywords:
6+
- iframe widget
7+
- iframe widget protocol
8+
- streamlit
9+
- embed external app
10+
- postMessage
11+
- mcpUrl
12+
- destructiveHint
13+
- sub-widgets
14+
- OpenBB Workspace
15+
---
16+
17+
import HeadTitle from '@site/src/components/General/HeadTitle.tsx';
18+
19+
<HeadTitle title="Iframe | OpenBB Workspace Docs" />
20+
21+
The Iframe widget embeds an external web application — for example a [Streamlit](https://streamlit.io/) dashboard, an internal tool, or any URL that permits embedding — directly inside an OpenBB Workspace dashboard.
22+
23+
On its own, an iframe just renders the URL. But by implementing the **Iframe Widget Protocol**, the embedded app can do much more:
24+
25+
- **Export sub-widgets** — declare tables and markdown sections inside the iframe that Workspace can pull out as standalone dashboard widgets.
26+
- **Receive toolbar parameters** — react to Workspace parameters (dropdowns, dates, toggles) without a backend round-trip.
27+
- **Auto-connect an MCP server** — wire up Copilot tools the moment the widget mounts.
28+
- **Auto-refresh on mutating tool calls** — remount the iframe after a destructive MCP tool runs so the UI reflects new state.
29+
30+
A complete working example (Streamlit app + MCP server + `widgets.json` + `apps.json`) lives in the [backends-for-openbb repository](https://github.com/OpenBB-finance/backends-for-openbb/tree/main/widget-examples/streamlit).
31+
32+
<img className="pro-border-gradient" width="800" alt="Iframe widget embedding a Streamlit app in OpenBB Workspace" src="https://openbb-assets.s3.us-east-1.amazonaws.com/docs/pro/iframe.png" />
33+
34+
## Widget definition
35+
36+
An iframe widget is declared in `widgets.json` with `type: "iframe"`. The `endpoint` is the initial `src` of the iframe (the user can still edit it via the widget's URL dialog).
37+
38+
```json
39+
{
40+
"portfolio_iframe": {
41+
"name": "Portfolio Dashboard (Streamlit)",
42+
"description": "Embedded Streamlit portfolio app with MCP tools",
43+
"category": "Portfolio",
44+
"type": "iframe",
45+
"endpoint": "http://localhost:8501",
46+
"storage": {
47+
"mcpUrl": "http://localhost:7769/mcp"
48+
},
49+
"gridData": { "w": 40, "h": 16 },
50+
"source": "Streamlit Demo"
51+
}
52+
}
53+
```
54+
55+
Two fields are specific to the iframe type:
56+
57+
- **`endpoint`** — for iframe widgets this is a full URL (the iframe `src`), not a backend path. It is the initial address loaded into the iframe.
58+
- **`storage.mcpUrl`***(optional)* an MCP server to auto-connect when the widget mounts. The tools exposed by that server become available to Copilot immediately, with no manual URL entry. If omitted, the user can still attach an MCP server manually via the **MCP** icon in the widget navbar.
59+
60+
:::note
61+
Some websites restrict being embedded in an iframe (via `X-Frame-Options` or `Content-Security-Policy`). OpenBB checks for this and only displays pages that permit embedding. Apps you run yourself (such as a local Streamlit app) typically allow it.
62+
:::
63+
64+
## The Iframe Widget Protocol
65+
66+
The protocol is a small set of `postMessage` events exchanged between the embedded app and Workspace. The embedded app announces what it offers, then responds to data requests and parameter updates.
67+
68+
### Messages from the iframe → Workspace
69+
70+
- **`openbb-connect`** — sent once on load to announce the available sub-widgets and parameters.
71+
72+
```js
73+
target.postMessage({
74+
type: "openbb-connect",
75+
widgets: manifests, // sub-widget manifests
76+
params: paramDefs // toolbar parameter definitions
77+
}, "*");
78+
```
79+
80+
- **`openbb-data`** — sent in response to an `openbb-request`, carrying the data for a sub-widget.
81+
82+
```js
83+
target.postMessage({
84+
type: "openbb-data",
85+
widgetId: "portfolio-holdings",
86+
dataType: "table", // "table" or "markdown"
87+
data: [ /* records */ ] // array of rows, or a markdown string
88+
}, "*");
89+
```
90+
91+
### Messages from Workspace → iframe
92+
93+
- **`openbb-request`** — Workspace asks the iframe for a sub-widget's data. A `widgetId` of `null` means "send everything."
94+
- **`openbb-params-update`** — Workspace pushes new toolbar parameter values to the iframe (e.g. the user changed a dropdown). The app reads these and re-renders.
95+
96+
### Sub-widget manifests
97+
98+
Each entry in the `widgets` array of `openbb-connect` describes one exportable sub-widget:
99+
100+
```js
101+
const WIDGET_MANIFESTS = [
102+
{
103+
"widgetId": "portfolio-holdings",
104+
"name": "Portfolio Holdings",
105+
"description": "Current portfolio positions with PnL",
106+
"category": "Portfolio",
107+
"dataType": "table" // "table" or "markdown"
108+
},
109+
{
110+
"widgetId": "market-summary",
111+
"name": "Market Summary",
112+
"description": "Weekly market analysis and outlook",
113+
"category": "Research",
114+
"dataType": "markdown"
115+
}
116+
];
117+
```
118+
119+
When the app announces sub-widgets, a grid icon with a count badge appears in the iframe widget's navbar. Clicking it lets the user add any sub-widget to the dashboard as a standalone widget.
120+
121+
### Parameter definitions
122+
123+
The `params` array of `openbb-connect` declares toolbar parameters Workspace should render for the widget. These mirror the standard [widget parameter](../widget-parameters/text-input) types:
124+
125+
```js
126+
const PARAM_DEFS = [
127+
{
128+
"paramName": "sector",
129+
"label": "Sector",
130+
"type": "text",
131+
"description": "Filter holdings by sector",
132+
"value": "All",
133+
"options": [{ "label": "All", "value": "All" }, /* ... */]
134+
},
135+
{
136+
"paramName": "min_shares",
137+
"label": "Min Shares",
138+
"type": "number",
139+
"value": "0",
140+
"min": 0, "max": 1000, "step": 10
141+
},
142+
{
143+
"paramName": "show_pnl_pct",
144+
"label": "Show PnL %",
145+
"type": "boolean",
146+
"value": "true"
147+
},
148+
{
149+
"paramName": "as_of_date",
150+
"label": "As Of Date",
151+
"type": "date",
152+
"value": "2026-04-07"
153+
}
154+
];
155+
```
156+
157+
When a user changes a parameter, Workspace sends an `openbb-params-update` message. A common pattern (used by the Streamlit example) is to mirror the values into the iframe's query string so the embedded app can read them on rerun.
158+
159+
### Minimal bridge
160+
161+
The bridge below is the complete client side of the protocol — announce on load, then answer requests. Inject it into your app's page (in Streamlit, via `st.components.v1.html(...)`).
162+
163+
```js
164+
(function () {
165+
const manifests = WIDGET_MANIFESTS;
166+
const paramDefs = PARAM_DEFS;
167+
const widgetData = WIDGET_DATA; // { widgetId: { type: "openbb-data", widgetId, dataType, data } }
168+
169+
const target = window.top || window.parent;
170+
171+
// Announce available sub-widgets + params
172+
if (target !== window) {
173+
target.postMessage({ type: "openbb-connect", widgets: manifests, params: paramDefs }, "*");
174+
}
175+
176+
window.addEventListener("message", function (event) {
177+
if (!event.data || !event.data.type) return;
178+
179+
if (event.data.type === "openbb-request") {
180+
const widgetId = event.data.widgetId;
181+
if (widgetId === null) {
182+
Object.values(widgetData).forEach((d) => target.postMessage(d, "*"));
183+
} else if (widgetData[widgetId]) {
184+
target.postMessage(widgetData[widgetId], "*");
185+
}
186+
}
187+
});
188+
})();
189+
```
190+
191+
## Auto-connecting an MCP server
192+
193+
Set `storage.mcpUrl` in the widget definition to attach an MCP server automatically when the iframe mounts. Its tools become available to Copilot with no manual setup:
194+
195+
```json
196+
"portfolio_iframe": {
197+
"type": "iframe",
198+
"endpoint": "http://localhost:8501",
199+
"storage": {
200+
"mcpUrl": "http://localhost:7769/mcp"
201+
}
202+
}
203+
```
204+
205+
This is most useful when shipping a pre-built app via `apps.json`: the dashboard loads with the iframe URL pre-set and the MCP server already connected, giving a true one-click experience.
206+
207+
## Auto-refresh on mutating MCP tools
208+
209+
When an MCP tool is connected to an iframe widget, Workspace can automatically remount the iframe after a tool call so the UI reflects the new state. **The default is no refresh** — only tools that explicitly opt in trigger a remount. This avoids unwanted reloads during read-only operations.
210+
211+
Opt in by marking the tool with `destructiveHint=True` in its annotations:
212+
213+
```python
214+
from mcp.server.fastmcp import FastMCP
215+
from mcp.types import ToolAnnotations
216+
217+
mcp = FastMCP("Portfolio Dashboard", host="0.0.0.0", port=7769)
218+
219+
# Read-only tool — no annotation needed (no refresh)
220+
@mcp.tool()
221+
def get_portfolio_holdings(sector: str = "All") -> str:
222+
...
223+
224+
# Mutating tool — opts in to iframe refresh
225+
@mcp.tool(annotations=ToolAnnotations(destructiveHint=True))
226+
def rebalance_portfolio() -> str:
227+
...
228+
```
229+
230+
After Copilot calls `rebalance_portfolio`, the iframe remounts and the embedded app reloads with the new state.
231+
232+
## Serving everything from one backend
233+
234+
A convenient pattern is to serve the MCP transport and the Workspace backend routes from a single process, so adding one URL in Workspace wires up tools, widget definitions, and app layout together. The Streamlit example mounts the Workspace routes alongside a FastMCP `streamable_http_app`:
235+
236+
| Route | Purpose |
237+
| ----- | ------- |
238+
| `/mcp` | MCP tools (FastMCP) |
239+
| `/widgets.json` | Widget definitions for OpenBB Workspace |
240+
| `/apps.json` | Pre-built app layout |
241+
| `/portfolio_note` | Markdown content for a companion note widget |
242+
243+
## Additional Resources
244+
245+
- Full working example: [Streamlit Iframe Widget Protocol demo](https://github.com/OpenBB-finance/backends-for-openbb/tree/main/widget-examples/streamlit)
246+
- [widgets.json Reference](../json-specs/widgets-json-reference)
247+
- [apps.json Reference](../json-specs/apps-json-reference)
248+
- [MCP Tools](../ai-features/mcp-tools)
26.2 KB
Binary file not shown.

scripts/generate_platform_markdown.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,38 @@
2929
# pylint: disable=redefined-outer-name
3030

3131

32+
def escape_mdx_curly_braces(text: Optional[str]) -> str:
33+
"""Escape curly braces so Docusaurus/MDX does not treat them as JSX expressions.
34+
35+
MDX interprets ``{...}`` in markdown as a JavaScript expression, which breaks the
36+
build when docstring-derived text contains literal braces (e.g. ``{congress}``).
37+
Braces inside fenced code blocks and inline code spans are left untouched, since
38+
MDX does not evaluate expressions there.
39+
40+
Parameters
41+
----------
42+
text: Optional[str]
43+
Text that may contain literal curly braces.
44+
45+
Returns
46+
-------
47+
str
48+
Text with curly braces outside of code escaped as HTML entities.
49+
"""
50+
51+
if not text:
52+
return text or ""
53+
54+
# Split keeping fenced code blocks and inline code spans as their own segments.
55+
# Captured delimiters land at odd indices; plain text lands at even indices.
56+
parts = re.split(r"(```.*?```|`[^`]*`)", text, flags=re.DOTALL)
57+
for i, part in enumerate(parts):
58+
if i % 2 == 0:
59+
parts[i] = part.replace("{", "&#123;").replace("}", "&#125;")
60+
61+
return "".join(parts)
62+
63+
3264
class Console:
3365
"""Console class to log messages to the console."""
3466

@@ -112,6 +144,8 @@ def create_reference_markdown_intro(
112144
Introduction section for the markdown file
113145
"""
114146

147+
description = escape_mdx_curly_braces(description)
148+
115149
deprecation_message = (
116150
":::caution Deprecated\n" f"{deprecated['message']}\n" ":::\n\n"
117151
if deprecated["flag"]
@@ -174,14 +208,14 @@ def create_reference_markdown_tabular_section(
174208
content = f"<TabItem value='{provider}' label='{provider}'>\n\n"
175209

176210
for i, param in enumerate(filtered):
177-
name = param.get("name", "")
211+
name = escape_mdx_curly_braces(param.get("name", ""))
178212
param_type = (
179213
param.get("type", "")
180214
.replace("Union[date | None, str]", "date | str | None")
181215
.replace("Union[date, str]", "date | str")
182216
.replace("Union[str, list[str]]", "str | list[str]")
183217
)
184-
description = param.get("description", "")
218+
description = escape_mdx_curly_braces(param.get("description", ""))
185219

186220
# Use bold and code formatting instead of headings
187221
content += f"**{name}**: `{param_type}`<br/>\n"
@@ -190,7 +224,7 @@ def create_reference_markdown_tabular_section(
190224

191225
# Only show default if it exists
192226
if default not in (None, "", "None"):
193-
content += f"*Default:* {default}<br/>\n"
227+
content += f"*Default:* {escape_mdx_curly_braces(str(default))}<br/>\n"
194228

195229
if description:
196230
# Format the description to preserve newlines and indentation
@@ -224,14 +258,14 @@ def create_reference_markdown_tabular_section(
224258
if isinstance(options, list):
225259
# List format
226260
for option in options:
227-
content += f"- {option}\n"
261+
content += f"- {escape_mdx_curly_braces(str(option))}\n"
228262
elif isinstance(options, str):
229263
# String format - might be comma-separated or already formatted
230264
if "," in options:
231265
for option in options.split(","):
232-
content += f"- {option.strip()}\n"
266+
content += f"- {escape_mdx_curly_braces(option.strip())}\n"
233267
else:
234-
content += f"- {options}\n"
268+
content += f"- {escape_mdx_curly_braces(options)}\n"
235269

236270
content += "</details>\n\n"
237271

@@ -272,7 +306,7 @@ def create_reference_markdown_returns_section(returns: List[Dict[str, str]]) ->
272306
# Process each return item
273307
for params in returns:
274308
if isinstance(params, dict):
275-
name = params.get("name", "")
309+
name = escape_mdx_curly_braces(params.get("name", ""))
276310
type_str = params.get("type", "")
277311
description = params.get("description", "")
278312

@@ -287,11 +321,11 @@ def create_reference_markdown_returns_section(returns: List[Dict[str, str]]) ->
287321
markdown += "```\n\n"
288322
else:
289323
# For single-line descriptions, use regular paragraph formatting
290-
markdown += f"{description}\n\n"
324+
markdown += f"{escape_mdx_curly_braces(description)}\n\n"
291325

292326
elif isinstance(params, str):
293327
# For simple string returns, just add them directly
294-
markdown += f"{params}\n"
328+
markdown += f"{escape_mdx_curly_braces(params)}\n"
295329

296330
markdown += "---\n"
297331

@@ -677,7 +711,7 @@ def generate_platform_markdown(paths: Dict) -> None:
677711
reference_markdown_content += create_reference_markdown_intro(
678712
path[1:], description, path_data["deprecated"]
679713
)
680-
reference_markdown_content += path_data["examples"]
714+
reference_markdown_content += escape_mdx_curly_braces(path_data["examples"])
681715

682716
if path_parameters_fields := path_data["parameters"]:
683717
reference_markdown_content += create_reference_markdown_tabular_section(

0 commit comments

Comments
 (0)