diff --git a/examples/cloud/chatgpt_app/requirements.txt b/examples/cloud/chatgpt_app/requirements.txt deleted file mode 100644 index 7fb1602c6..000000000 --- a/examples/cloud/chatgpt_app/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -# Core framework dependency -mcp-agent @ file://../../../ # Link to the local mcp-agent project root diff --git a/examples/cloud/chatgpt_app/README.md b/examples/cloud/chatgpt_apps/basic_app/README.md similarity index 100% rename from examples/cloud/chatgpt_app/README.md rename to examples/cloud/chatgpt_apps/basic_app/README.md diff --git a/examples/cloud/chatgpt_app/main.py b/examples/cloud/chatgpt_apps/basic_app/main.py similarity index 99% rename from examples/cloud/chatgpt_app/main.py rename to examples/cloud/chatgpt_apps/basic_app/main.py index ecdfbbd6d..3f56713e9 100644 --- a/examples/cloud/chatgpt_app/main.py +++ b/examples/cloud/chatgpt_apps/basic_app/main.py @@ -192,4 +192,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(main()) \ No newline at end of file diff --git a/examples/cloud/chatgpt_app/mcp_agent.config.yaml b/examples/cloud/chatgpt_apps/basic_app/mcp_agent.config.yaml similarity index 100% rename from examples/cloud/chatgpt_app/mcp_agent.config.yaml rename to examples/cloud/chatgpt_apps/basic_app/mcp_agent.config.yaml diff --git a/examples/cloud/chatgpt_apps/basic_app/requirements.txt b/examples/cloud/chatgpt_apps/basic_app/requirements.txt new file mode 100644 index 000000000..8e434b517 --- /dev/null +++ b/examples/cloud/chatgpt_apps/basic_app/requirements.txt @@ -0,0 +1,2 @@ +# Core framework dependency +mcp-agent @ file://../../../../ # Link to the local mcp-agent project root diff --git a/examples/cloud/chatgpt_app/web/.gitignore b/examples/cloud/chatgpt_apps/basic_app/web/.gitignore similarity index 100% rename from examples/cloud/chatgpt_app/web/.gitignore rename to examples/cloud/chatgpt_apps/basic_app/web/.gitignore diff --git a/examples/cloud/chatgpt_app/web/README.md b/examples/cloud/chatgpt_apps/basic_app/web/README.md similarity index 100% rename from examples/cloud/chatgpt_app/web/README.md rename to examples/cloud/chatgpt_apps/basic_app/web/README.md diff --git a/examples/cloud/chatgpt_app/web/package.json b/examples/cloud/chatgpt_apps/basic_app/web/package.json similarity index 100% rename from examples/cloud/chatgpt_app/web/package.json rename to examples/cloud/chatgpt_apps/basic_app/web/package.json diff --git a/examples/cloud/chatgpt_app/web/public/index.html b/examples/cloud/chatgpt_apps/basic_app/web/public/index.html similarity index 100% rename from examples/cloud/chatgpt_app/web/public/index.html rename to examples/cloud/chatgpt_apps/basic_app/web/public/index.html diff --git a/examples/cloud/chatgpt_app/web/src/components/App.css b/examples/cloud/chatgpt_apps/basic_app/web/src/components/App.css similarity index 100% rename from examples/cloud/chatgpt_app/web/src/components/App.css rename to examples/cloud/chatgpt_apps/basic_app/web/src/components/App.css diff --git a/examples/cloud/chatgpt_app/web/src/components/App.tsx b/examples/cloud/chatgpt_apps/basic_app/web/src/components/App.tsx similarity index 100% rename from examples/cloud/chatgpt_app/web/src/components/App.tsx rename to examples/cloud/chatgpt_apps/basic_app/web/src/components/App.tsx diff --git a/examples/cloud/chatgpt_app/web/src/components/Coin.css b/examples/cloud/chatgpt_apps/basic_app/web/src/components/Coin.css similarity index 100% rename from examples/cloud/chatgpt_app/web/src/components/Coin.css rename to examples/cloud/chatgpt_apps/basic_app/web/src/components/Coin.css diff --git a/examples/cloud/chatgpt_app/web/src/components/Coin.tsx b/examples/cloud/chatgpt_apps/basic_app/web/src/components/Coin.tsx similarity index 100% rename from examples/cloud/chatgpt_app/web/src/components/Coin.tsx rename to examples/cloud/chatgpt_apps/basic_app/web/src/components/Coin.tsx diff --git a/examples/cloud/chatgpt_app/web/src/index.css b/examples/cloud/chatgpt_apps/basic_app/web/src/index.css similarity index 100% rename from examples/cloud/chatgpt_app/web/src/index.css rename to examples/cloud/chatgpt_apps/basic_app/web/src/index.css diff --git a/examples/cloud/chatgpt_app/web/src/index.tsx b/examples/cloud/chatgpt_apps/basic_app/web/src/index.tsx similarity index 100% rename from examples/cloud/chatgpt_app/web/src/index.tsx rename to examples/cloud/chatgpt_apps/basic_app/web/src/index.tsx diff --git a/examples/cloud/chatgpt_app/web/src/utils/dev-openai-global.ts b/examples/cloud/chatgpt_apps/basic_app/web/src/utils/dev-openai-global.ts similarity index 100% rename from examples/cloud/chatgpt_app/web/src/utils/dev-openai-global.ts rename to examples/cloud/chatgpt_apps/basic_app/web/src/utils/dev-openai-global.ts diff --git a/examples/cloud/chatgpt_app/web/src/utils/hooks/use-openai-global.ts b/examples/cloud/chatgpt_apps/basic_app/web/src/utils/hooks/use-openai-global.ts similarity index 100% rename from examples/cloud/chatgpt_app/web/src/utils/hooks/use-openai-global.ts rename to examples/cloud/chatgpt_apps/basic_app/web/src/utils/hooks/use-openai-global.ts diff --git a/examples/cloud/chatgpt_app/web/src/utils/hooks/use-theme.ts b/examples/cloud/chatgpt_apps/basic_app/web/src/utils/hooks/use-theme.ts similarity index 100% rename from examples/cloud/chatgpt_app/web/src/utils/hooks/use-theme.ts rename to examples/cloud/chatgpt_apps/basic_app/web/src/utils/hooks/use-theme.ts diff --git a/examples/cloud/chatgpt_app/web/src/utils/hooks/use-widget-state.ts b/examples/cloud/chatgpt_apps/basic_app/web/src/utils/hooks/use-widget-state.ts similarity index 100% rename from examples/cloud/chatgpt_app/web/src/utils/hooks/use-widget-state.ts rename to examples/cloud/chatgpt_apps/basic_app/web/src/utils/hooks/use-widget-state.ts diff --git a/examples/cloud/chatgpt_app/web/src/utils/types.ts b/examples/cloud/chatgpt_apps/basic_app/web/src/utils/types.ts similarity index 100% rename from examples/cloud/chatgpt_app/web/src/utils/types.ts rename to examples/cloud/chatgpt_apps/basic_app/web/src/utils/types.ts diff --git a/examples/cloud/chatgpt_app/web/tsconfig.json b/examples/cloud/chatgpt_apps/basic_app/web/tsconfig.json similarity index 100% rename from examples/cloud/chatgpt_app/web/tsconfig.json rename to examples/cloud/chatgpt_apps/basic_app/web/tsconfig.json diff --git a/examples/cloud/chatgpt_app/web/yarn.lock b/examples/cloud/chatgpt_apps/basic_app/web/yarn.lock similarity index 100% rename from examples/cloud/chatgpt_app/web/yarn.lock rename to examples/cloud/chatgpt_apps/basic_app/web/yarn.lock diff --git a/examples/cloud/chatgpt_apps/timer/README.md b/examples/cloud/chatgpt_apps/timer/README.md new file mode 100644 index 000000000..836cf314d --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/README.md @@ -0,0 +1,224 @@ +# Timer App - ChatGPT App Example + +![timer-app](https://github.com/user-attachments/assets/7a526501-84c8-4ef5-b784-4b3948790db2) + +This example demonstrates how to create an MCP Agent application with interactive UI widgets for OpenAI's ChatGPT Apps platform. It shows how to build a countdown timer widget that renders interactive UI components directly in the ChatGPT interface. + +**SSE Endpoint to try out! -** `https://1bs7spincr4wvnl2aq2n9880tye30msv.deployments.mcp-agent.com/sse` + +## Motivation + +This example showcases the integration between mcp-agent and OpenAI's ChatGPT Apps SDK, specifically demonstrating: + +- **Widget-based UI**: Creating interactive widgets that render in ChatGPT +- **Resource templates**: Serving HTML/JS/CSS as MCP resources +- **Tool invocation metadata**: Using OpenAI-specific metadata for tool behavior +- **Static asset serving**: Two approaches for serving client-side code (inline vs. deployed) + +## Concepts Demonstrated + +- Creating MCP tools with OpenAI widget metadata +- Serving interactive HTML/JS/CSS widgets through MCP resources +- Using `EmbeddedResource` to pass UI templates to ChatGPT +- Handling tool calls that return structured content for widget hydration +- Deploying web clients alongside MCP servers + +## Components in this Example + +1. **TimerWidget**: A dataclass that encapsulates all widget metadata: + - Widget identifier and title + - Template URI (cached by ChatGPT) + - Tool invocation state messages + - HTML template content + - Response text + +> [!TIP] +> The widget HTML templates are heavily cached by OpenAI Apps. Use date-based URIs (like `ui://widget/timer-10-30-2025-12-00.html`) to bust the cache when updating the widget. + +2. **MCP Server**: FastMCP server configured for stateless HTTP with: + + - Tool registration (`timer` tool with hours, minutes, seconds, and optional message parameters) + - Resource serving (HTML template) + - Resource template registration + - Custom request handlers for tools and resources + +3. **Web Client**: A React application (in `web/` directory) that: + - Renders an interactive countdown timer interface with hours, minutes, and seconds + - Displays an optional custom message below the timer (e.g., "Meeting starts soon!") + - Hydrates with structured data from tool calls + - Provides Start and Reset controls + - Shows visual completion indicator with "Time's up!" message + - Notifies ChatGPT when the timer completes + - Uses shadcn/ui components for consistent styling + +## Static Asset Serving Approaches + +The example demonstrates two methods for serving the web client assets: + +### Method 1: Inline Assets (Default) + +Embeds the JavaScript and CSS directly into the HTML template. This approach: + +- Works immediately for initial deployment +- Can lead to large HTML templates +- May have string escaping issues +- Best for initial development and testing + +### Method 2: Deployed Assets (Recommended) + +References static files from a deployed server URL: + +- Smaller HTML templates +- Better performance with caching +- Requires initial deployment to get the server URL +- Best for production use + +## Prerequisites + +- Python 3.10+ +- [UV](https://github.com/astral-sh/uv) package manager +- Node.js and npm/yarn (for building the web client) + +## Building the Web Client + +Before running the server, you need to build the React web client: + +```bash +cd web +yarn install +yarn build +cd .. +``` + +This creates optimized production assets in `web/build/` that the server will serve. + +## Test Locally + +Install the dependencies: + +```bash +uv pip install -r requirements.txt +``` + +Spin up the mcp-agent server locally with SSE transport: + +```bash +uv run main.py +``` + +This will: + +- Start the MCP server on port 8000 +- Serve the web client at http://127.0.0.1:8000 +- Serve static assets (JS/CSS) at http://127.0.0.1:8000/static + +Use [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to explore and test the server: + +```bash +npx @modelcontextprotocol/inspector --transport sse --server-url http://127.0.0.1:8000/sse +``` + +In MCP Inspector: + +- Click **Tools > List Tools** to see the `timer` tool +- Click **Resources > List Resources** to see the widget HTML template +- Run the `timer` tool with parameters (e.g., `{"hours": 0, "minutes": 5, "seconds": 0, "message": "Coffee break!"}`) to see the widget metadata and structured result + +## Deploy to mcp-agent Cloud + +You can deploy this MCP-Agent app as a hosted mcp-agent app in the Cloud. + +1. In your terminal, authenticate into mcp-agent cloud by running: + +```bash +uv run mcp-agent login +``` + +2. You will be redirected to the login page, create an mcp-agent cloud account through Google or Github + +3. Set up your mcp-agent cloud API Key and copy & paste it into your terminal + +```bash +uv run mcp-agent login +INFO: Directing to MCP Agent Cloud API login... +Please enter your API key =: +``` + +4. In your terminal, deploy the MCP app: + +```bash +uv run mcp-agent deploy chatgpt-app --no-auth +``` + +Note the use of `--no-auth` flag here will allow unauthenticated access to this server using its URL. + +The `deploy` command will bundle the app files and deploy them, producing a server URL of the form: +`https://.deployments.mcp-agent.com`. + +5. After deployment, update main.py:767 with your actual server URL: + +```python +SERVER_URL = "https://.deployments.mcp-agent.com" +``` + +6. Switch to using deployed assets (optional but recommended): + +Update main.py:782 to use `DEPLOYED_HTML_TEMPLATE`: + +```python +html=DEPLOYED_HTML_TEMPLATE, +``` + +Then bump the template uri: + +```python +template_uri="ui://widget/timer-.html", +``` + +Then redeploy: + +```bash +uv run mcp-agent deploy chatgpt-app --no-auth +``` + +## Using with OpenAI ChatGPT Apps + +Once deployed, you can integrate this server with ChatGPT Apps: + +1. In your OpenAI platform account, create a new ChatGPT App +2. Configure the app to connect to your deployed MCP server URL +3. The `timer` tool will appear as an available action +4. When invoked with time parameters (hours, minutes, seconds), the widget will render in the ChatGPT interface with an interactive countdown timer +5. Users can click Start to begin the countdown and Reset to reset the timer + +## Test Deployment + +Use [MCP Inspector](https://github.com/modelcontextprotocol/inspector) to explore and test this server: + +```bash +npx @modelcontextprotocol/inspector --transport sse --server-url https://.deployments.mcp-agent.com/sse +``` + +Make sure Inspector is configured with the following settings: + +| Setting | Value | +| ---------------- | --------------------------------------------------- | +| _Transport Type_ | _SSE_ | +| _SSE_ | _https://[server_id].deployments.mcp-agent.com/sse_ | + +## Code Structure + +- `main.py` - Defines the MCP server, widget metadata, and tool handlers for the timer +- `web/` - React web client for the countdown timer widget + - `web/src/components/Timer.tsx` - Main timer component with countdown logic + - `web/src/components/ui/` - shadcn/ui components (Card, Button) + - `web/src/components/App.tsx` - Root app component + - `web/src/utils/types.ts` - TypeScript type definitions + - `web/build/` - Production build output (generated) + - `web/public/` - Static assets +- `mcp_agent.config.yaml` - App configuration (execution engine, name) +- `requirements.txt` - Python dependencies + +## Additional Resources + +- [OpenAI Apps SDK Documentation](https://developers.openai.com/apps-sdk/build/mcp-server) diff --git a/examples/cloud/chatgpt_apps/timer/main.py b/examples/cloud/chatgpt_apps/timer/main.py new file mode 100644 index 000000000..25ccee56b --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/main.py @@ -0,0 +1,334 @@ +"""Basic MCP mcp-agent app integration with OpenAI Apps SDK. + +The server exposes widget-backed tools that render the UI bundle within the +client directory. Each handler returns the HTML shell via an MCP resource and +returns structured content so the ChatGPT client can hydrate the widget.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any, Dict, List + +from starlette.routing import Mount +from starlette.staticfiles import StaticFiles +import uvicorn +from pathlib import Path +import mcp.types as types +from mcp.server.fastmcp import FastMCP +from mcp_agent.app import MCPApp +from mcp_agent.server.app_server import create_mcp_server_for_app + + +@dataclass(frozen=True) +class TimerWidget: + identifier: str + title: str + template_uri: str + invoking: str + invoked: str + html: str + response_text: str + + +BUILD_DIR = Path(__file__).parent / "web" / "build" +ASSETS_DIR = BUILD_DIR / "static" + +# Providing the JS and CSS to the app can be done in 1 of 2 ways: +# 1) Load the content as text from the static build files and inline them into the HTML template +# 2) (Preferred) Reference the static files served from the deployed server +# Since (2) depends on an initial deployment of the server, it is recommended to use approach (1) first +# and then switch to (2) once the server is deployed and its URL is available. +# (2) is preferred since (1) can lead to large HTML templates and potential for string escaping issues. + + +# Make sure these paths align with the build output paths (dynamic per build) +JS_PATH = ASSETS_DIR / "js" / "main.50dd757e.js" +CSS_PATH = ASSETS_DIR / "css" / "main.bf8e60c9.css" + + +# METHOD 1: Inline the JS and CSS into the HTML template +TIMER_JS = JS_PATH.read_text(encoding="utf-8") +TIMER_CSS = CSS_PATH.read_text(encoding="utf-8") + +INLINE_HTML_TEMPLATE = f""" +
+ + +""" + +# METHOD 2: Reference the static files from the deployed server +SERVER_URL = "https://.deployments.mcp-agent.com" # e.g. "https://15da9n6bk2nj3wiwf7ghxc2fy7sc6c8a.deployments.mcp-agent.com" +DEPLOYED_HTML_TEMPLATE = ( + '
\n' + f'\n' + f'' +) + + +WIDGET = TimerWidget( + identifier="timer", + title="Timer", + # OpenAI Apps heavily cache resource by URI, so use a date-based URI to bust the cache when updating the app. + template_uri="ui://widget/timer-10-30-2025-12-00.html", + invoking="Preparing timer", + invoked="Starting the timer...", + html=INLINE_HTML_TEMPLATE, # Use INLINE_HTML_TEMPLATE or DEPLOYED_HTML_TEMPLATE + response_text="Timer started! The timer will count down from the specified duration.", +) + + +MIME_TYPE = "text/html+skybridge" + +mcp = FastMCP( + name="timer", + stateless_http=True, +) +app = MCPApp( + name="timer", description="Timer widget for counting down within an OpenAI chat", mcp=mcp +) + + +def _resource_description() -> str: + return "Timer widget markup" + + +def _tool_meta() -> Dict[str, Any]: + return { + "openai/outputTemplate": WIDGET.template_uri, + "openai/toolInvocation/invoking": WIDGET.invoking, + "openai/toolInvocation/invoked": WIDGET.invoked, + "openai/widgetAccessible": True, + "openai/resultCanProduceWidget": True, + "annotations": { + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + }, + } + + +def _embedded_widget_resource() -> types.EmbeddedResource: + return types.EmbeddedResource( + type="resource", + resource=types.TextResourceContents( + uri=WIDGET.template_uri, + mimeType=MIME_TYPE, + text=WIDGET.html, + title=WIDGET.title, + ), + ) + + +@mcp._mcp_server.list_tools() +async def _list_tools() -> List[types.Tool]: + return [ + types.Tool( + name=WIDGET.identifier, + title=WIDGET.title, + inputSchema={ + "type": "object", + "properties": { + "hours": { + "type": "integer", + "description": "Number of hours for the timer (0-23)", + "minimum": 0, + "default": 0 + }, + "minutes": { + "type": "integer", + "description": "Number of minutes for the timer (0-59)", + "minimum": 0, + "maximum": 59, + "default": 0 + }, + "seconds": { + "type": "integer", + "description": "Number of seconds for the timer (0-59)", + "minimum": 0, + "maximum": 59, + "default": 0 + }, + "message": { + "type": "string", + "description": "Optional message to display under the timer (e.g., '🥚 Soft boil eggs', '☕️ Coffee brewing', '📗 Study time!'). If not provided, shows default countdown message.", + "default": "" + } + }, + "required": [] + }, + description="Start a countdown timer with specified hours, minutes, and seconds", + _meta=_tool_meta(), + ) + ] + + +@mcp._mcp_server.list_resources() +async def _list_resources() -> List[types.Resource]: + return [ + types.Resource( + name=WIDGET.title, + title=WIDGET.title, + uri=WIDGET.template_uri, + description=_resource_description(), + mimeType=MIME_TYPE, + _meta=_tool_meta(), + ) + ] + + +@mcp._mcp_server.list_resource_templates() +async def _list_resource_templates() -> List[types.ResourceTemplate]: + return [ + types.ResourceTemplate( + name=WIDGET.title, + title=WIDGET.title, + uriTemplate=WIDGET.template_uri, + description=_resource_description(), + mimeType=MIME_TYPE, + _meta=_tool_meta(), + ) + ] + + +async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult: + if str(req.params.uri) != WIDGET.template_uri: + return types.ServerResult( + types.ReadResourceResult( + contents=[], + _meta={"error": f"Unknown resource: {req.params.uri}"}, + ) + ) + + contents = [ + types.TextResourceContents( + uri=WIDGET.template_uri, + mimeType=MIME_TYPE, + text=WIDGET.html, + _meta=_tool_meta(), + ) + ] + + return types.ServerResult(types.ReadResourceResult(contents=contents)) + + +async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: + if req.params.name != WIDGET.identifier: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Unknown tool: {req.params.name}", + ) + ], + isError=True, + ) + ) + + # Extract timer parameters from the request + args = req.params.arguments or {} + hours = args.get("hours", 0) + minutes = args.get("minutes", 0) + seconds = args.get("seconds", 0) + message = args.get("message", "") + + widget_resource = _embedded_widget_resource() + meta: Dict[str, Any] = { + "openai.com/widget": widget_resource.model_dump(mode="json"), + "openai/outputTemplate": WIDGET.template_uri, + "openai/toolInvocation/invoking": WIDGET.invoking, + "openai/toolInvocation/invoked": WIDGET.invoked, + "openai/widgetAccessible": True, + "openai/resultCanProduceWidget": True, + } + + # Format time for display + time_parts = [] + if hours > 0: + time_parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + if minutes > 0: + time_parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") + if seconds > 0: + time_parts.append(f"{seconds} second{'s' if seconds != 1 else ''}") + + time_str = ", ".join(time_parts) if time_parts else "0 seconds" + + response_text = f"Timer set for {time_str}" + if message: + response_text += f" - {message}" + response_text += ". Click Start to begin the countdown!" + + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=response_text, + ) + ], + structuredContent={ + "hours": hours, + "minutes": minutes, + "seconds": seconds, + "message": message, + "isRunning": False, + "isPaused": False + }, + _meta=meta, + ) + ) + + +mcp._mcp_server.request_handlers[types.CallToolRequest] = _call_tool_request +mcp._mcp_server.request_handlers[types.ReadResourceRequest] = _handle_read_resource + + +# NOTE: This main function is for local testing; it spins up the MCP server (SSE) and +# serves the static assets for the web client. You can view the tool results / resources +# in MCP Inspector. +# Client development/testing should be done using the development webserver spun up via `yarn start` +# in the `web/` directory. +async def main(): + async with app.run() as timer_app: + mcp_server = create_mcp_server_for_app(timer_app) + + ASSETS_DIR = BUILD_DIR / "static" + if not ASSETS_DIR.exists(): + raise FileNotFoundError( + f"Assets directory not found at {ASSETS_DIR}. " + "Please build the web client before running the server." + ) + + starlette_app = mcp_server.sse_app() + + # This serves the static css and js files referenced by the HTML + starlette_app.routes.append( + Mount("/static", app=StaticFiles(directory=ASSETS_DIR), name="static") + ) + + # This serves the main HTML file at the root path for the server + starlette_app.routes.append( + Mount( + "/", + app=StaticFiles(directory=BUILD_DIR, html=True), + name="root", + ) + ) + + # Serve via uvicorn, mirroring FastMCP.run_sse_async + config = uvicorn.Config( + starlette_app, + host=mcp_server.settings.host, + port=int(mcp_server.settings.port), + ) + server = uvicorn.Server(config) + await server.serve() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/cloud/chatgpt_apps/timer/mcp_agent.config.yaml b/examples/cloud/chatgpt_apps/timer/mcp_agent.config.yaml new file mode 100644 index 000000000..b11e4e04c --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/mcp_agent.config.yaml @@ -0,0 +1,2 @@ +name: openai-timer-app +execution_engine: asyncio diff --git a/examples/cloud/chatgpt_apps/timer/requirements.txt b/examples/cloud/chatgpt_apps/timer/requirements.txt new file mode 100644 index 000000000..857376dd0 --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/requirements.txt @@ -0,0 +1,2 @@ +# Core framework dependency +mcp-agent \ No newline at end of file diff --git a/examples/cloud/chatgpt_apps/timer/web/.gitignore b/examples/cloud/chatgpt_apps/timer/web/.gitignore new file mode 100644 index 000000000..4d29575de --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/web/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/cloud/chatgpt_apps/timer/web/README.md b/examples/cloud/chatgpt_apps/timer/web/README.md new file mode 100644 index 000000000..a1e956d65 --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/web/README.md @@ -0,0 +1,25 @@ +A basic coin flip component initialized with create-react-app. + +## Setup + +### Install dependencies + +```bash +yarn install +``` + +### Dev Flow + +Run the following to start the local dev server and view the app in your browser. + +```bash +yarn start +``` + +### Building + +Run the following to build the app in preparation for deploying to mcp-agent cloud. + +```bash +yarn build +``` diff --git a/examples/cloud/chatgpt_apps/timer/web/package.json b/examples/cloud/chatgpt_apps/timer/web/package.json new file mode 100644 index 000000000..f979582aa --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/web/package.json @@ -0,0 +1,42 @@ +{ + "name": "timer", + "version": "0.1.0", + "private": true, + "dependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.126", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-scripts": "5.0.1", + "typescript": "^4.9.5", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/cloud/chatgpt_apps/timer/web/public/index.html b/examples/cloud/chatgpt_apps/timer/web/public/index.html new file mode 100644 index 000000000..9e72f3ecc --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/web/public/index.html @@ -0,0 +1,17 @@ + + + + + + + + Timer + + + +
+ + diff --git a/examples/cloud/chatgpt_apps/timer/web/src/components/App.css b/examples/cloud/chatgpt_apps/timer/web/src/components/App.css new file mode 100644 index 000000000..daa55c78e --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/web/src/components/App.css @@ -0,0 +1,70 @@ +.App { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + transition: background-color 0.3s ease, color 0.3s ease; +} + +/* Light theme (default) */ +.App.light { + background-color: #ffffff; + color: #333333; +} + +.App.light .instruction-text { + color: #333333; +} + +/* Dark theme */ +.App.dark { + background-color: #1a1a1a; + color: #e0e0e0; +} + +.App.dark .instruction-text { + color: #e0e0e0; +} + +.instruction-text { + font-size: 1.2rem; + margin-top: 1rem; + transition: color 0.3s ease; +} + +.App-logo { + height: 40vmin; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .App-logo { + animation: App-logo-spin infinite 20s linear; + } +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/examples/cloud/chatgpt_apps/timer/web/src/components/App.tsx b/examples/cloud/chatgpt_apps/timer/web/src/components/App.tsx new file mode 100644 index 000000000..eeb93c76d --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/web/src/components/App.tsx @@ -0,0 +1,51 @@ +import { useTheme } from "src/utils/hooks/use-theme"; +import "./App.css"; +import { Timer } from "./Timer"; +import { useWidgetState } from "src/utils/hooks/use-widget-state"; +import { useOpenAiGlobal } from "src/utils/hooks/use-openai-global"; +import { TimerWidgetState } from "src/utils/types"; + +function App() { + const theme = useTheme(); + const toolOutput = useOpenAiGlobal("toolOutput") as TimerWidgetState | null; + const [widgetState, setWidgetState] = useWidgetState(); + + // Prioritize toolOutput (from MCP server) over widgetState for initial values + // toolOutput contains the parameters passed to the timer tool + const hours = toolOutput?.hours ?? widgetState?.hours ?? 0; + const minutes = toolOutput?.minutes ?? widgetState?.minutes ?? 0; + const seconds = toolOutput?.seconds ?? widgetState?.seconds ?? 0; + const message = toolOutput?.message ?? widgetState?.message ?? ""; + + const handleTimerUpdate = (h: number, m: number, s: number, running: boolean) => { + setWidgetState({ + hours: h, + minutes: m, + seconds: s, + message: message, + isRunning: running, + isPaused: false + }); + + // Notify the model when timer completes + if (h === 0 && m === 0 && s === 0 && !running) { + window.openai?.sendFollowUpMessage({ + prompt: "The timer has completed!", + }); + } + }; + + return ( +
+ +
+ ); +} + +export default App; diff --git a/examples/cloud/chatgpt_apps/timer/web/src/components/Timer.css b/examples/cloud/chatgpt_apps/timer/web/src/components/Timer.css new file mode 100644 index 000000000..3382b3f33 --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/web/src/components/Timer.css @@ -0,0 +1,143 @@ +.timer-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 1.5rem; +} + +.timer-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.timer-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1.25rem; + font-weight: 600; +} + +.timer-icon { + width: 1.5rem; + height: 1.5rem; +} + +.timer-description { + text-align: center; + font-size: 0.875rem; + color: #6b7280; +} + +.timer-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 0; + width: 100%; +} + +.timer-grid { + display: grid; + width: 100%; + gap: 0.5rem; +} + +.timer-labels { + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: center; + justify-items: center; + gap: 1rem; +} + +.timer-label { + text-align: center; + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +.timer-values { + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: center; + justify-items: center; + gap: 1rem; +} + +.timer-value { + text-align: center; + font-weight: bold; + font-size: 2rem; + color: #111827; +} + +.timer-buttons { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; +} + +.timer-buttons button { + width: 100%; +} + +.timer-buttons button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +[data-theme="dark"] .timer-wrapper { + color: #f9fafb; +} + +[data-theme="dark"] .timer-description { + color: #9ca3af; +} + +[data-theme="dark"] .timer-label { + color: #d1d5db; +} + +[data-theme="dark"] .timer-value { + color: #f9fafb; +} + +/* Completed state styling */ +.timer-completed { + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.8; + } +} + +.timer-value-completed { + color: #16a34a !important; + font-weight: 900; +} + +.timer-completed .timer-description { + color: #16a34a; + font-weight: 600; + font-size: 1rem; +} + +[data-theme="dark"] .timer-value-completed { + color: #22c55e !important; +} + +[data-theme="dark"] .timer-completed .timer-description { + color: #22c55e; +} diff --git a/examples/cloud/chatgpt_apps/timer/web/src/components/Timer.tsx b/examples/cloud/chatgpt_apps/timer/web/src/components/Timer.tsx new file mode 100644 index 000000000..4f220d283 --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/web/src/components/Timer.tsx @@ -0,0 +1,180 @@ +import { useState, useEffect, useRef } from "react"; +import { Card, CardHeader, CardContent } from "./ui/card"; +import { Button } from "./ui/button"; +import "./Timer.css"; + +interface TimerProps { + initialHours: number; + initialMinutes: number; + initialSeconds: number; + message?: string; + onTimerUpdate?: (hours: number, minutes: number, seconds: number, isRunning: boolean) => void; +} + +export function Timer({ initialHours, initialMinutes, initialSeconds, message = "", onTimerUpdate }: TimerProps) { + const [hours, setHours] = useState(initialHours); + const [minutes, setMinutes] = useState(initialMinutes); + const [seconds, setSeconds] = useState(initialSeconds); + const [isRunning, setIsRunning] = useState(false); + const [isCompleted, setIsCompleted] = useState(false); + const intervalRef = useRef(null); + + // Store initial values for reset + const initialTimeRef = useRef({ + hours: initialHours, + minutes: initialMinutes, + seconds: initialSeconds + }); + + useEffect(() => { + // Update initial values when props change + initialTimeRef.current = { + hours: initialHours, + minutes: initialMinutes, + seconds: initialSeconds + }; + setHours(initialHours); + setMinutes(initialMinutes); + setSeconds(initialSeconds); + setIsCompleted(false); + }, [initialHours, initialMinutes, initialSeconds]); + + useEffect(() => { + if (isRunning) { + intervalRef.current = setInterval(() => { + // Use a ref to get current values and calculate new time atomically + setHours((h) => { + setMinutes((m) => { + setSeconds((s) => { + // Calculate total seconds and decrement + let totalSeconds = h * 3600 + m * 60 + s - 1; + + // Check if timer completed + if (totalSeconds <= 0) { + setIsRunning(false); + setIsCompleted(true); + setHours(0); + setMinutes(0); + if (onTimerUpdate) { + onTimerUpdate(0, 0, 0, false); + } + return 0; + } + + // Calculate new time components + const newHours = Math.floor(totalSeconds / 3600); + const newMinutes = Math.floor((totalSeconds % 3600) / 60); + const newSeconds = totalSeconds % 60; + + // Update states + setHours(newHours); + setMinutes(newMinutes); + + return newSeconds; + }); + return m; + }); + return h; + }); + }, 1000); + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [isRunning, onTimerUpdate]); + + const handleStart = () => { + if (hours === 0 && minutes === 0 && seconds === 0) { + return; + } + setIsRunning(true); + }; + + const handleReset = () => { + setIsRunning(false); + setIsCompleted(false); + setHours(initialTimeRef.current.hours); + setMinutes(initialTimeRef.current.minutes); + setSeconds(initialTimeRef.current.seconds); + if (onTimerUpdate) { + onTimerUpdate( + initialTimeRef.current.hours, + initialTimeRef.current.minutes, + initialTimeRef.current.seconds, + false + ); + } + }; + + const formatTime = (value: number): string => { + return value.toString().padStart(2, "0"); + }; + + return ( + +
+ +
+ +
Timer
+
+
+ {isCompleted + ? "Time's up!" + : message || "Countdown to zero from the initial duration."} +
+
+ +
+
+
Hours
+
Minutes
+
Seconds
+
+
+
{formatTime(hours)}
+
{formatTime(minutes)}
+
{formatTime(seconds)}
+
+
+
+ + +
+
+
+
+ ); +} + +function ClockIcon(props: React.SVGProps) { + return ( + + + + + ); +} diff --git a/examples/cloud/chatgpt_apps/timer/web/src/components/ui/button.tsx b/examples/cloud/chatgpt_apps/timer/web/src/components/ui/button.tsx new file mode 100644 index 000000000..72dd4d1a5 --- /dev/null +++ b/examples/cloud/chatgpt_apps/timer/web/src/components/ui/button.tsx @@ -0,0 +1,67 @@ +import * as React from "react" + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: "default" | "outline" + size?: "default" | "sm" | "lg" +} + +const Button = React.forwardRef( + ({ className, variant = "default", size = "default", ...props }, ref) => { + const baseStyles: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: '6px', + fontSize: '14px', + fontWeight: 500, + transition: 'all 0.2s', + cursor: 'pointer', + border: 'none', + outline: 'none', + } + + const sizeStyles: React.CSSProperties = { + default: { + padding: '0.5rem 1rem', + height: '40px', + }, + sm: { + padding: '0.375rem 0.75rem', + height: '36px', + }, + lg: { + padding: '0.625rem 1.25rem', + height: '44px', + }, + }[size] + + const variantStyles: React.CSSProperties = { + default: { + backgroundColor: '#3b82f6', + color: 'white', + }, + outline: { + backgroundColor: 'transparent', + border: '1px solid #e5e7eb', + color: '#374151', + }, + }[variant] + + return ( +