diff --git a/build-all.mts b/build-all.mts index abd9832..7d6a8b2 100644 --- a/build-all.mts +++ b/build-all.mts @@ -15,6 +15,7 @@ const PER_ENTRY_CSS_IGNORE = "**/*.module.*".split(",").map((s) => s.trim()); const GLOBAL_CSS_LIST = [path.resolve("src/index.css")]; const targets: string[] = [ + "test", "todo", "solar-system", "pizzaz", diff --git a/src/test/index.jsx b/src/test/index.jsx new file mode 100644 index 0000000..156fb9a --- /dev/null +++ b/src/test/index.jsx @@ -0,0 +1,7 @@ +import { createRoot } from "react-dom/client"; +import App from "./test"; + +createRoot(document.getElementById("test-root")).render(); + +export { App }; +export default App; diff --git a/src/test/test.css b/src/test/test.css new file mode 100644 index 0000000..e69de29 diff --git a/src/test/test.jsx b/src/test/test.jsx new file mode 100644 index 0000000..7c50703 --- /dev/null +++ b/src/test/test.jsx @@ -0,0 +1,120 @@ +import React from "react"; +import { useWidgetProps } from "../use-widget-props"; +import { useWidgetState } from "../use-widget-state"; +import { useDisplayMode } from "../use-display-mode"; +import { useEffect } from "react"; + +const ExpandIcon = () => { + return ( + + + + ); +}; + +export function App() { + const widgetProps = useWidgetProps() || {}; + const { title_text = 'hi' } = widgetProps; + const displayMode = useDisplayMode(); + const maxHeight = "100vh"; + + const [titleText, setTitleText] = useWidgetState(title_text); + const [isLoading, setIsLoading] = useWidgetState(false); + + useEffect(() => { + setTitleText(title_text); + }, [title_text, setTitleText]); + + const helloAgain = async () => { + //await window.openai.sendFollowUpMessage({ "prompt": "can you show the test app again with the title 'hello again.'" }); + setIsLoading(true); + try { + const reply = await window.openai.callTool("test-tool", { + "title_text": "hi again. :)" + }); + if (reply?.structuredContent?.title_text) { + setTitleText(reply.structuredContent.title_text); + } + } finally { + setIsLoading(false); + } + }; + + const gotoDoc = () => { + window.openai.openExternal({ href: "https://developers.openai.com/apps-sdk" }); + }; + + return ( +
+ {displayMode !== "fullscreen" && ( +
+ +
+ )} + +
+
{titleText || 'hi'}
+ + +
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/test_server_python/main.py b/test_server_python/main.py new file mode 100644 index 0000000..6fd34ef --- /dev/null +++ b/test_server_python/main.py @@ -0,0 +1,284 @@ +"""Test demo MCP server implemented with the Python FastMCP helper. + +Each handler returns the HTML shell via an MCP resource and renders the title text. +The module also wires the handlers into an HTTP/SSE stack so you can +run the server with uvicorn on port 8000.""" + +from __future__ import annotations + +import os +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Dict, List + +import mcp.types as types +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +# Get base URL from environment or use local static files +BASE_URL = os.getenv("STATIC_BASE_URL", "/static") + +@dataclass(frozen=True) +class TestWidget: + identifier: str + title: str + template_uri: str + invoking: str + invoked: str + html: str + response_text: str + + +widgets: List[TestWidget] = [ + TestWidget( + identifier="test-tool", + title="Show Test", + template_uri="ui://widget/test.html", + invoking="Displaying test", + invoked="Rendered test", + html=( + f"
\n" + f"/static/test-2d2b.css\">\n" + f"" + ), + response_text="Rendered test component!", + ), +] + + +MIME_TYPE = "text/html+skybridge" + + +WIDGETS_BY_ID: Dict[str, TestWidget] = {widget.identifier: widget for widget in widgets} +WIDGETS_BY_URI: Dict[str, TestWidget] = {widget.template_uri: widget for widget in widgets} + + +class TestInput(BaseModel): + """Schema for Test tools.""" + + title_text: str = Field( + ..., + alias="titleText", + description="Title text used when rendering the test widget.", + ) + + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + +mcp = FastMCP( + name="test-server-python", + sse_path="/mcp", + message_path="/mcp/messages", + stateless_http=True, +) + + +TOOL_INPUT_SCHEMA: Dict[str, Any] = { + "type": "object", + "properties": { + "titleText": { + "type": "string", + "description": "Title text used when rendering the test widget.", + } + }, + "required": ["titleText"], + "additionalProperties": False, +} + + +def _resource_description(widget: TestWidget) -> str: + return f"{widget.title} widget markup" + + +def _tool_meta(widget: TestWidget) -> Dict[str, Any]: + return { + "openai/outputTemplate": widget.template_uri, + "openai/toolInvocation/invoking": widget.invoking, + "openai/toolInvocation/invoked": widget.invoked, + "openai/widgetAccessible": True, + "openai/widgetPrefersBorder": True, + "openai/resultCanProduceWidget": True, + "annotations": { + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + } + } + + +def _embedded_widget_resource(widget: TestWidget) -> 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, + description=widget.title, + inputSchema=deepcopy(TOOL_INPUT_SCHEMA), + _meta=_tool_meta(widget), + ) + for widget in widgets + ] + + +@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(widget), + mimeType=MIME_TYPE, + _meta=_tool_meta(widget), + ) + for widget in widgets + ] + + +@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(widget), + mimeType=MIME_TYPE, + _meta=_tool_meta(widget), + ) + for widget in widgets + ] + + +async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult: + widget = WIDGETS_BY_URI.get(str(req.params.uri)) + if widget is None: + 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(widget), + ) + ] + + return types.ServerResult(types.ReadResourceResult(contents=contents)) + + +async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: + widget = WIDGETS_BY_ID.get(req.params.name) + if widget is None: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Unknown tool: {req.params.name}", + ) + ], + isError=True, + ) + ) + + arguments = req.params.arguments or {} + try: + payload = TestInput.model_validate(arguments) + except ValidationError as exc: + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Input validation error: {exc.errors()}", + ) + ], + isError=True, + ) + ) + + titleText = payload.title_text + + widget_resource = _embedded_widget_resource(widget) + 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, + } + + structured = { + "title_text": titleText + } + + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=widget.response_text, + ) + ], + structuredContent=structured, + _meta=meta, + ) + ) + + +mcp._mcp_server.request_handlers[types.CallToolRequest] = _call_tool_request +mcp._mcp_server.request_handlers[types.ReadResourceRequest] = _handle_read_resource + + +app = mcp.streamable_http_app() + +# Add static file serving +try: + from starlette.staticfiles import StaticFiles + import os + + # Get the directory where this script is located + script_dir = os.path.dirname(os.path.abspath(__file__)) + static_dir = os.path.join(script_dir, "static") + + app.mount("/static", StaticFiles(directory=static_dir), name="static") +except Exception: + pass + +try: + from starlette.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=False, + ) +except Exception: + pass + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host="0.0.0.0", port=8000) diff --git a/test_server_python/static/copy-assets-files-here.txt b/test_server_python/static/copy-assets-files-here.txt new file mode 100644 index 0000000..3389200 --- /dev/null +++ b/test_server_python/static/copy-assets-files-here.txt @@ -0,0 +1 @@ +See README.md \ No newline at end of file