Skip to content

Commit 5fb4ae5

Browse files
initial commit
Co-authored-by: bryant-openai <bryant-openai@users.noCo-authored-by: bryant-openai <bryant-openai@users.noreply.github.com>
0 parents  commit 5fb4ae5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+11553
-0
lines changed

.gitignore

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Dependency directories
2+
node_modules/
3+
.pnpm-store/
4+
5+
# Build outputs
6+
/assets
7+
dist/
8+
build/
9+
.vite/
10+
coverage/
11+
12+
# Logs and caches
13+
*.log
14+
pnpm-debug.log
15+
npm-debug.log
16+
yarn-error.log
17+
*.tsbuildinfo
18+
.eslintcache
19+
20+
# Environment files
21+
.env
22+
.env.local
23+
.env.*.local
24+
!.env.example
25+
26+
# Editor and OS files
27+
.DS_Store
28+
.idea/
29+
.vscode/
30+
31+
# Python artifacts
32+
__pycache__/
33+
*.py[cod]
34+
*.egg-info/
35+
.venv/
36+

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 OpenAI
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.

README.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Apps SDK Examples Gallery
2+
3+
[![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
4+
5+
This repository showcases example UI components to be used with the Apps SDK, as well as example MCP servers that expose a collection of components as tools.
6+
It is meant to be used as a starting point and source of inspiration to build your own apps for ChatGPT.
7+
8+
## MCP + Apps SDK overview
9+
10+
The Model Context Protocol (MCP) is an open specification for connecting large language model clients to external tools, data, and user interfaces. An MCP server exposes tools that a model can call during a conversation and returns results according to the tool contracts. Those results can include extra metadata—such as inline HTML—that the Apps SDK uses to render rich UI components (widgets) alongside assistant messages.
11+
12+
Within the Apps SDK, MCP keeps the server, model, and UI in sync. By standardizing the wire format, authentication, and metadata, it lets ChatGPT reason about your connector the same way it reasons about built-in tools. A minimal MCP integration for Apps SDK implements three capabilities:
13+
14+
1. **List tools** – Your server advertises the tools it supports, including their JSON Schema input/output contracts and optional annotations (for example, `readOnlyHint`).
15+
2. **Call tools** – When a model selects a tool, it issues a `call_tool` request with arguments that match the user intent. Your server executes the action and returns structured content the model can parse.
16+
3. **Return widgets** – Alongside structured content, return embedded resources in the response metadata so the Apps SDK can render the interface inline in the Apps SDK client (ChatGPT).
17+
18+
Because the protocol is transport agnostic, you can host the server over Server-Sent Events or streaming HTTP—Apps SDK supports both.
19+
20+
The MCP servers in this demo highlight how each tool can light up widgets by combining structured payloads with `_meta.openai/outputTemplate` metadata returned from the MCP servers.
21+
22+
## Repository structure
23+
24+
- `src/` – Source for each widget example.
25+
- `assets/` – Generated HTML, JS, and CSS bundles after running the build step.
26+
- `pizzaz_server_node/` – MCP server implemented with the official TypeScript SDK.
27+
- `pizzaz_server_python/` – Python MCP server that returns the Pizzaz widgets.
28+
- `solar-system_server_python/` – Python MCP server for the 3D solar system widget.
29+
- `build-all.mts` – Vite build orchestrator that produces hashed bundles for every widget entrypoint.
30+
31+
## Prerequisites
32+
33+
- Node.js 18+
34+
- pnpm (recommended) or npm/yarn
35+
- Python 3.10+ (for the Python MCP server)
36+
37+
## Install dependencies
38+
39+
Clone the repository and install the workspace dependencies:
40+
41+
```bash
42+
pnpm install
43+
```
44+
45+
> Using npm or yarn? Install the root dependencies with your preferred client and adjust the commands below accordingly.
46+
47+
## Build the components gallery
48+
49+
The components are bundled into standalone assets that the MCP servers serve as reusable UI resources.
50+
51+
```bash
52+
pnpm run build
53+
```
54+
55+
This command runs `build-all.mts`, producing versioned `.html`, `.js`, and `.css` files inside `assets/`. Each widget is wrapped with the CSS it needs so you can host the bundles directly or ship them with your own server.
56+
57+
To iterate locally, you can also launch the Vite dev server:
58+
59+
```bash
60+
pnpm run dev
61+
```
62+
63+
## Serve the static assets
64+
65+
If you want to preview the generated bundles without the MCP servers, start the static file server after running a build:
66+
67+
```bash
68+
pnpm run serve
69+
```
70+
71+
The assets are exposed at [`http://localhost:4444`](http://localhost:4444) with CORS enabled so that local tooling (including MCP inspectors) can fetch them.
72+
73+
## Run the MCP servers
74+
75+
The repository ships several demo MCP servers that highlight different widget bundles:
76+
77+
- **Pizzaz (Node & Python)** – pizza-inspired collection of tools and components
78+
- **Solar system (Python)** – 3D solar system viewer
79+
80+
Every tool response includes plain text content, structured JSON, and `_meta.openai/outputTemplate` metadata so the Apps SDK can hydrate the matching widget.
81+
82+
### Pizzaz Node server
83+
84+
```bash
85+
cd pizzaz_server_node
86+
pnpm start
87+
```
88+
89+
### Pizzaz Python server
90+
91+
```bash
92+
python -m venv .venv
93+
source .venv/bin/activate
94+
pip install -r pizzaz_server_python/requirements.txt
95+
uvicorn pizzaz_server_python.main:app --port 8000
96+
```
97+
98+
### Solar system Python server
99+
100+
```bash
101+
python -m venv .venv
102+
source .venv/bin/activate
103+
pip install -r solar-system_server_python/requirements.txt
104+
uvicorn solar-system_server_python.main:app --port 8000
105+
```
106+
107+
You can reuse the same virtual environment for all Python servers—install the dependencies once and run whichever entry point you need.
108+
109+
## Testing in ChatGPT
110+
111+
To add these apps to ChatGPT, enable [developer mode](https://platform.openai.com/docs/guides/developer-mode), and add your apps in Settings > Connectors.
112+
113+
To add your local server without deploying it, you can use a tool like [ngrok](https://ngrok.com/) to expose your local server to the internet.
114+
115+
For example, once your mcp servers are running, you can run:
116+
117+
```bash
118+
ngrok http 8000
119+
```
120+
121+
You will get a public URL that you can use to add your local server to ChatGPT in Settings > Connectors.
122+
123+
For example: `https://<custom_endpoint>.ngrok-free.app/mcp`
124+
125+
## Next steps
126+
127+
- Customize the widget data: edit the handlers in `pizzaz_server_node/src`, `pizzaz_server_python/main.py`, or the solar system server to fetch data from your systems.
128+
- Create your own components and add them to the gallery: drop new entries into `src/` and they will be picked up automatically by the build script.
129+
130+
## Contributing
131+
132+
You are welcome to open issues or submit PRs to improve this app, however, please note that we may not review all suggestions.
133+
134+
## License
135+
136+
This project is licensed under the MIT License. See [LICENSE](./LICENSE) for details.

build-all.mts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { build, type InlineConfig, type Plugin } from "vite";
2+
import react from "@vitejs/plugin-react";
3+
import fg from "fast-glob";
4+
import path from "path";
5+
import fs from "fs";
6+
import crypto from "crypto";
7+
import pkg from "./package.json" with { type: "json" };
8+
import tailwindcss from "@tailwindcss/vite";
9+
10+
const entries = fg.sync("src/**/index.{tsx,jsx}");
11+
const outDir = "assets";
12+
13+
const PER_ENTRY_CSS_GLOB = "**/*.{css,pcss,scss,sass}";
14+
const PER_ENTRY_CSS_IGNORE = "**/*.module.*".split(",").map((s) => s.trim());
15+
const GLOBAL_CSS_LIST = [path.resolve("src/index.css")];
16+
17+
const targets: string[] = [
18+
"todo",
19+
"solar-system",
20+
"pizzaz",
21+
"pizzaz-carousel",
22+
"pizzaz-list",
23+
"pizzaz-albums",
24+
"pizzaz-video",
25+
];
26+
const builtNames: string[] = [];
27+
28+
function wrapEntryPlugin(
29+
virtualId: string,
30+
entryFile: string,
31+
cssPaths: string[]
32+
): Plugin {
33+
return {
34+
name: `virtual-entry-wrapper:${entryFile}`,
35+
resolveId(id) {
36+
if (id === virtualId) return id;
37+
},
38+
load(id) {
39+
if (id !== virtualId) {
40+
return null;
41+
}
42+
43+
const cssImports = cssPaths
44+
.map((css) => `import ${JSON.stringify(css)};`)
45+
.join("\n");
46+
47+
return `
48+
${cssImports}
49+
export * from ${JSON.stringify(entryFile)};
50+
51+
import * as __entry from ${JSON.stringify(entryFile)};
52+
export default (__entry.default ?? __entry.App);
53+
54+
import ${JSON.stringify(entryFile)};
55+
`;
56+
},
57+
};
58+
}
59+
60+
fs.rmSync(outDir, { recursive: true, force: true });
61+
62+
for (const file of entries) {
63+
const name = path.basename(path.dirname(file));
64+
if (targets.length && !targets.includes(name)) {
65+
continue;
66+
}
67+
68+
const entryAbs = path.resolve(file);
69+
const entryDir = path.dirname(entryAbs);
70+
71+
// Collect CSS for this entry using the glob(s) rooted at its directory
72+
const perEntryCss = fg.sync(PER_ENTRY_CSS_GLOB, {
73+
cwd: entryDir,
74+
absolute: true,
75+
dot: false,
76+
ignore: PER_ENTRY_CSS_IGNORE,
77+
});
78+
79+
// Global CSS (Tailwind, etc.), only include those that exist
80+
const globalCss = GLOBAL_CSS_LIST.filter((p) => fs.existsSync(p));
81+
82+
// Final CSS list (global first for predictable cascade)
83+
const cssToInclude = [...globalCss, ...perEntryCss].filter((p) =>
84+
fs.existsSync(p)
85+
);
86+
87+
const virtualId = `\0virtual-entry:${entryAbs}`;
88+
89+
const createConfig = (): InlineConfig => ({
90+
plugins: [
91+
wrapEntryPlugin(virtualId, entryAbs, cssToInclude),
92+
tailwindcss(),
93+
react(),
94+
{
95+
name: "remove-manual-chunks",
96+
outputOptions(options) {
97+
if ("manualChunks" in options) {
98+
delete (options as any).manualChunks;
99+
}
100+
return options;
101+
},
102+
},
103+
],
104+
esbuild: {
105+
jsx: "automatic",
106+
jsxImportSource: "react",
107+
target: "es2022",
108+
},
109+
build: {
110+
target: "es2022",
111+
outDir,
112+
emptyOutDir: false,
113+
chunkSizeWarningLimit: 2000,
114+
minify: "esbuild",
115+
cssCodeSplit: false,
116+
rollupOptions: {
117+
input: virtualId,
118+
output: {
119+
format: "es",
120+
entryFileNames: `${name}.js`,
121+
inlineDynamicImports: true,
122+
assetFileNames: (info) =>
123+
(info.name || "").endsWith(".css")
124+
? `${name}.css`
125+
: `[name]-[hash][extname]`,
126+
},
127+
preserveEntrySignatures: "allow-extension",
128+
treeshake: true,
129+
},
130+
},
131+
});
132+
133+
console.group(`Building ${name} (react)`);
134+
await build(createConfig());
135+
console.groupEnd();
136+
builtNames.push(name);
137+
console.log(`Built ${name}`);
138+
}
139+
140+
const outputs = fs
141+
.readdirSync("assets")
142+
.filter((f) => f.endsWith(".js") || f.endsWith(".css"))
143+
.map((f) => path.join("assets", f))
144+
.filter((p) => fs.existsSync(p));
145+
146+
const renamed = [];
147+
148+
const h = crypto
149+
.createHash("sha256")
150+
.update(pkg.version, "utf8")
151+
.digest("hex")
152+
.slice(0, 4);
153+
154+
console.group("Hashing outputs");
155+
for (const out of outputs) {
156+
const dir = path.dirname(out);
157+
const ext = path.extname(out);
158+
const base = path.basename(out, ext);
159+
const newName = path.join(dir, `${base}-${h}${ext}`);
160+
161+
fs.renameSync(out, newName);
162+
renamed.push({ old: out, neu: newName });
163+
console.log(`${out} -> ${newName}`);
164+
}
165+
console.groupEnd();
166+
167+
console.log("new hash: ", h);
168+
169+
for (const name of builtNames) {
170+
const dir = outDir;
171+
const htmlPath = path.join(dir, `${name}-${h}.html`);
172+
const cssPath = path.join(dir, `${name}-${h}.css`);
173+
const jsPath = path.join(dir, `${name}-${h}.js`);
174+
175+
const css = fs.existsSync(cssPath)
176+
? fs.readFileSync(cssPath, { encoding: "utf8" })
177+
: "";
178+
const js = fs.existsSync(jsPath)
179+
? fs.readFileSync(jsPath, { encoding: "utf8" })
180+
: "";
181+
182+
const cssBlock = css ? `\n <style>\n${css}\n </style>\n` : "";
183+
const jsBlock = js ? `\n <script type="module">\n${js}\n </script>` : "";
184+
185+
const html = [
186+
"<!doctype html>",
187+
"<html>",
188+
`<head>${cssBlock}</head>`,
189+
"<body>",
190+
` <div id="${name}-root"></div>${jsBlock}`,
191+
"</body>",
192+
"</html>",
193+
].join("\n");
194+
fs.writeFileSync(htmlPath, html, { encoding: "utf8" });
195+
console.log(`${htmlPath} (generated)`);
196+
}

0 commit comments

Comments
 (0)