diff --git a/app/app.css b/app/app.css index c1b15a9..1f0a692 100644 --- a/app/app.css +++ b/app/app.css @@ -1,88 +1,83 @@ +/** + * Design System Styles + * + * These styles follow the OpenAI Apps SDK design guidelines: + * https://developers.openai.com/apps-sdk/concepts/design-guidelines + * + * Key principles: + * - Platform-native system fonts (SF Pro, Roboto) + * - Limited font size variation (body and body-small preferred) + * - System colors for text, icons, and spatial elements + * - DO NOT override backgrounds or text colors (let system control these) + * - Brand colors ONLY on primary buttons/CTAs + * - WCAG AA contrast ratios for accessibility + * - Consistent spacing and corner radius + */ + @import 'tailwindcss' source('.'); @theme { --font-sans: - 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display', + 'Roboto', system-ui, ui-sans-serif, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; } html, body { - @media (prefers-color-scheme: dark) { - color-scheme: dark; - } + /* Support both light and dark color schemes to match parent frame */ + color-scheme: light dark; + /* Transparent background to inherit from parent frame when embedded */ + background: transparent !important; } :root { - --radius: 0.5rem; + /* System-consistent corner radius per OpenAI Apps SDK guidelines */ + --radius: 0.75rem; - --background: oklch(98% 0.02 320); - --foreground: oklch(20% 0.06 320); + /* Inherit system background and foreground - don't override */ + /* Per guidelines: partners should not override backgrounds or text colors */ + --background: transparent; + --foreground: light-dark(oklch(0% 0 0), oklch(100% 0 0)); - --card: oklch(99% 0.015 320); - --card-foreground: oklch(20% 0.06 320); + /* Minimal card surface - barely distinguishable from background */ + --card: light-dark(oklch(98% 0 0), oklch(10% 0 0)); + --card-foreground: light-dark(oklch(0% 0 0), oklch(100% 0 0)); - --popover: oklch(99% 0.015 320); - --popover-foreground: oklch(20% 0.06 320); + /* Popover surfaces */ + --popover: light-dark(oklch(100% 0 0), oklch(10% 0 0)); + --popover-foreground: light-dark(oklch(0% 0 0), oklch(100% 0 0)); + /* Brand accent for primary actions (buttons, CTAs) - only place for brand color */ --primary: oklch(65% 0.15 320); - --primary-foreground: oklch(98% 0.02 320); + --primary-foreground: oklch(100% 0 0); - --secondary: oklch(85% 0.08 330); - --secondary-foreground: oklch(20% 0.06 320); + /* Secondary UI elements - neutral grays */ + --secondary: light-dark(oklch(95% 0 0), oklch(20% 0 0)); + --secondary-foreground: light-dark(oklch(0% 0 0), oklch(100% 0 0)); - --muted: oklch(92% 0.04 320); - --muted-foreground: oklch(40% 0.07 320); + /* Muted elements - subtle text */ + --muted: light-dark(oklch(96% 0 0), oklch(15% 0 0)); + --muted-foreground: light-dark(oklch(45% 0 0), oklch(65% 0 0)); - --accent: oklch(88% 0.06 340); - --accent-foreground: oklch(20% 0.06 320); + /* Accent elements (badges, highlights) - use sparingly */ + --accent: light-dark(oklch(96% 0 0), oklch(15% 0 0)); + --accent-foreground: light-dark(oklch(0% 0 0), oklch(100% 0 0)); - --border: oklch(88% 0.05 320); - --input: oklch(88% 0.05 320); - --input-invalid: oklch(65% 0.2 0); + /* Borders and dividers - spatial elements only */ + --border: light-dark(oklch(85% 0 0), oklch(30% 0 0)); + --input: light-dark(oklch(85% 0 0), oklch(30% 0 0)); + --input-invalid: oklch(60% 0.2 20); - --destructive: oklch(60% 0.18 0); - --destructive-foreground: oklch(98% 0.02 320); - --foreground-destructive: oklch(55% 0.18 0); + /* Destructive actions */ + --destructive: oklch(55% 0.22 25); + --destructive-foreground: oklch(100% 0 0); + --foreground-destructive: oklch(50% 0.22 25); + /* Focus ring - use brand accent */ --ring: oklch(65% 0.15 320); } -@media (prefers-color-scheme: dark) { - :root { - --background: oklch(15% 0.05 320); - --foreground: oklch(95% 0.02 320); - - --card: oklch(18% 0.05 320); - --card-foreground: oklch(95% 0.02 320); - - --popover: oklch(18% 0.05 320); - --popover-foreground: oklch(95% 0.02 320); - - --primary: oklch(70% 0.18 320); - --primary-foreground: oklch(15% 0.05 320); - - --secondary: oklch(30% 0.08 330); - --secondary-foreground: oklch(95% 0.02 320); - - --muted: oklch(25% 0.06 320); - --muted-foreground: oklch(65% 0.05 320); - - --accent: oklch(28% 0.06 340); - --accent-foreground: oklch(95% 0.02 320); - - --border: oklch(28% 0.06 320); - --input: oklch(28% 0.06 320); - --input-invalid: oklch(50% 0.18 0); - - --destructive: oklch(55% 0.18 0); - --destructive-foreground: oklch(95% 0.02 320); - --foreground-destructive: oklch(65% 0.2 0); - - --ring: oklch(70% 0.18 320); - } -} - @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -112,56 +107,43 @@ body { } @theme { - --text-mega: 5rem; - --text-mega--line-height: 5.25rem; - --text-mega--font-weight: 700; - --text-h1: 3.5rem; - --text-h1--line-height: 3.875rem; - --text-h1--font-weight: 700; - --text-h2: 2.5rem; - --text-h2--line-height: 3rem; - --text-h2--font-weight: 700; - --text-h3: 2rem; - --text-h3--line-height: 2.25rem; - --text-h3--font-weight: 700; - --text-h4: 1.75rem; - --text-h4--line-height: 2.25rem; - --text-h4--font-weight: 700; - --text-h5: 1.5rem; - --text-h5--line-height: 2rem; - --text-h5--font-weight: 700; - --text-h6: 1rem; - --text-h6--line-height: 1.25rem; - --text-h6--font-weight: 700; - --text-body-2xl: 2rem; - --text-body-2xl--line-height: 2.25rem; - --text-body-xl: 1.75rem; - --text-body-xl--line-height: 2.25rem; - --text-body-lg: 1.5rem; - --text-body-lg--line-height: 2rem; - --text-body-md: 1.25rem; - --text-body-md--line-height: 1.75rem; - --text-body-sm: 1rem; + /* Simplified typography scale per OpenAI Apps SDK guidelines */ + /* Limit variation in font size, preferring body and body-small sizes */ + --text-h1: 1.5rem; + --text-h1--line-height: 2rem; + --text-h1--font-weight: 600; + --text-h2: 1.25rem; + --text-h2--line-height: 1.75rem; + --text-h2--font-weight: 600; + --text-h3: 1.125rem; + --text-h3--line-height: 1.5rem; + --text-h3--font-weight: 600; + --text-body: 1rem; + --text-body--line-height: 1.5rem; + --text-body--font-weight: 400; + --text-body-sm: 0.875rem; --text-body-sm--line-height: 1.25rem; - --text-body-xs: 0.875rem; - --text-body-xs--line-height: 1.125rem; - --text-body-2xs: 0.75rem; - --text-body-2xs--line-height: 1rem; - --text-caption: 1.125rem; - --text-caption--line-height: 1.5rem; - --text-caption--font-weight: 600; - --text-button: 0.75rem; - --text-button--line-height: 1rem; - --text-button--font-weight: 700; + --text-body-sm--font-weight: 400; + --text-caption: 0.75rem; + --text-caption--line-height: 1rem; + --text-caption--font-weight: 500; } @utility container { + /* System grid spacing per OpenAI Apps SDK guidelines */ margin-inline: auto; - padding-inline: 2rem; + padding-inline: 1rem; + + @media (width >= 768px) { + & { + padding-inline: 1.5rem; + } + } - @media (width >=1400px) { + @media (width >= 1400px) { & { max-width: 1400px; + padding-inline: 2rem; } } } diff --git a/app/root.tsx b/app/root.tsx index 68fdea7..267b5eb 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -16,6 +16,7 @@ export function Layout({ children }: { children: React.ReactNode }) { + @@ -47,6 +48,7 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { details = error.message stack = error.stack } + console.error(error) return (
diff --git a/app/routes.ts b/app/routes.ts index 1692343..7d9680b 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -2,6 +2,9 @@ import { type RouteConfig, index, route } from '@react-router/dev/routes' export default [ index('routes/index.tsx'), + // chat gpt hosts your app on their own server and serves it at /index.html + route('index.html', 'routes/chat-gpt-app/index.tsx'), + route('authorize', 'routes/authorize.tsx'), route('ui/token-input', 'routes/ui/token-input.tsx'), route('ui/journal-viewer', 'routes/ui/journal-viewer.tsx'), diff --git a/app/routes/chat-gpt-app/index.tsx b/app/routes/chat-gpt-app/index.tsx new file mode 100644 index 0000000..2dd8004 --- /dev/null +++ b/app/routes/chat-gpt-app/index.tsx @@ -0,0 +1,259 @@ +import { useState, useTransition } from 'react' +import { + ErrorBoundary, + useErrorBoundary, + type FallbackProps, +} from 'react-error-boundary' +import { z } from 'zod' +import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' +import { + useMcpUiInit, + sendMcpMessage, + waitForRenderData, +} from '#app/utils/mcp.ts' +import { useDoubleCheck, useUnmountSignal } from '#app/utils/misc.ts' +import { type Route } from './+types/index.tsx' + +export async function clientLoader() { + const renderDataSchema = z + .object({ + toolOutput: z.object({ + entry: z.object({ + id: z.number(), + title: z.string(), + content: z.string(), + tags: z.array(z.object({ id: z.number(), name: z.string() })), + mood: z.string().nullable().optional(), + location: z.string().nullable().optional(), + weather: z.string().nullable().optional(), + createdAt: z.number(), + updatedAt: z.number(), + }), + }), + }) + .passthrough() + const renderData = await waitForRenderData(renderDataSchema) + console.log({ renderData }) + return { entry: renderData.toolOutput.entry } +} + +export function HydrateFallback() { + return ( +
+ + + + +

+ Waiting for journal entries... +

+
+ ) +} + +export default function EntryViewerContent({ + loaderData, +}: Route.ComponentProps) { + const { entry } = loaderData + const [isDeleted, setIsDeleted] = useState(false) + + useMcpUiInit() + + if (isDeleted) { + return ( +
+
+
+

+ Entry Deleted +

+

+ Entry deleted successfully +

+
+
+
+ ) + } + + return ( +
+
+
+
+

+ {entry.title} +

+
+ +
+ {entry.tags.length > 0 ? ( + entry.tags.map((tag) => ( + + 🏷️ {tag.name} + + )) + ) : ( + No tags + )} +
+ +
+ {entry.mood && ( +
+ 💭 + + {entry.mood} + +
+ )} + {entry.location && ( +
+ 📍 + + {entry.location} + +
+ )} + {entry.weather && ( +
+ 🌤️ + + {entry.weather} + +
+ )} +
+ 📅 + + Created: {new Date(entry.createdAt * 1000).toLocaleDateString()} + +
+ {entry.updatedAt !== entry.createdAt && ( +
+ ✏️ + + Updated:{' '} + {new Date(entry.updatedAt * 1000).toLocaleDateString()} + +
+ )} +
+
+ +
+

+ Content +

+
+ {entry.content} +
+
+ +
+ setIsDeleted(true)} + /> +
+
+
+ ) +} + +function DeleteEntryButton({ + entry, + onDeleted, +}: { + entry: { id: number; title: string } + onDeleted: () => void +}) { + return ( + + + + ) +} + +function DeleteEntryError({ error, resetErrorBoundary }: FallbackProps) { + return ( +
+

Failed to delete entry

+

{error.message}

+ +
+ ) +} + +function DeleteEntryButtonImpl({ + entry, + onDeleted, +}: { + entry: { id: number; title: string } + onDeleted: () => void +}) { + const [isPending, startTransition] = useTransition() + const { doubleCheck, getButtonProps } = useDoubleCheck() + const { showBoundary } = useErrorBoundary() + const unmountSignal = useUnmountSignal() + + const handleDelete = async () => { + if (!doubleCheck) return + + startTransition(async () => { + try { + await sendMcpMessage( + 'tool', + { toolName: 'delete_entry', params: { id: entry.id } }, + { signal: unmountSignal }, + ) + onDeleted() + } catch (err) { + showBoundary(err) + } + }) + } + + return ( + + ) +} + +export { GeneralErrorBoundary as ErrorBoundary } diff --git a/app/routes/ui/journal-viewer.tsx b/app/routes/ui/journal-viewer.tsx index 57305bd..17534fa 100644 --- a/app/routes/ui/journal-viewer.tsx +++ b/app/routes/ui/journal-viewer.tsx @@ -13,7 +13,7 @@ import { import { useDoubleCheck, useUnmountSignal } from '#app/utils/misc.ts' import { type Route } from './+types/journal-viewer.tsx' -export async function clientLoader({ request }: Route.ClientLoaderArgs) { +export async function clientLoader() { const renderData = await waitForRenderData( z.object({ entries: z.array( @@ -24,7 +24,6 @@ export async function clientLoader({ request }: Route.ClientLoaderArgs) { }), ), }), - { signal: request.signal, timeoutMs: 3_000 }, ) return { entries: renderData.entries } } diff --git a/app/utils/mcp-ui-compat.client.ts b/app/utils/mcp-ui-compat.client.ts new file mode 100644 index 0000000..49d0c5c --- /dev/null +++ b/app/utils/mcp-ui-compat.client.ts @@ -0,0 +1,434 @@ +// thanks https://gist.github.com/liady/aa3a4095f39f859a5877d1bcd710476c +/* eslint-disable */ +// @ts-nocheck + +var __defProp = Object.defineProperty +var __defNormalProp = (obj, key, value) => + key in obj + ? __defProp(obj, key, { + enumerable: true, + configurable: true, + writable: true, + value, + }) + : (obj[key] = value) +var __publicField = (obj, key, value) => { + __defNormalProp(obj, typeof key !== 'symbol' ? key + '' : key, value) + return value +} +// src/adapters/appssdk/adapter-runtime.ts +var MCPUIAppsSdkAdapter = class { + constructor(config = {}) { + __publicField(this, 'config') + __publicField(this, 'pendingRequests', /* @__PURE__ */ new Map()) + __publicField(this, 'messageIdCounter', 0) + __publicField(this, 'originalPostMessage', null) + this.config = { + logger: config.logger || console, + hostOrigin: config.hostOrigin || window.location.origin, + timeout: config.timeout || 3e4, + intentHandling: config.intentHandling || 'prompt', + } + } + /** + * Initialize the adapter and monkey-patch postMessage if Apps SDK is present + */ + install() { + if (!window.openai) { + this.config.logger.warn( + '[MCPUI-Apps SDK Adapter] window.openai not detected. Adapter will not activate.', + ) + return false + } + this.config.logger.log('[MCPUI-Apps SDK Adapter] Initializing adapter...') + this.patchPostMessage() + this.setupAppsSdkEventListeners() + this.sendRenderData() + this.config.logger.log( + '[MCPUI-Apps SDK Adapter] Adapter initialized successfully', + ) + return true + } + /** + * Clean up pending requests and restore original postMessage + */ + uninstall() { + for (const request of this.pendingRequests.values()) { + clearTimeout(request.timeoutId) + request.reject(new Error('Adapter uninstalled')) + } + this.pendingRequests.clear() + if (this.originalPostMessage) { + try { + window.parent.postMessage = this.originalPostMessage + this.config.logger.log( + '[MCPUI-Apps SDK Adapter] Restored original parent.postMessage', + ) + } catch (error) { + this.config.logger.error( + '[MCPUI-Apps SDK Adapter] Failed to restore original postMessage:', + error, + ) + } + } + this.config.logger.log('[MCPUI-Apps SDK Adapter] Adapter uninstalled') + } + /** + * Monkey-patch parent.postMessage to intercept MCP-UI messages + * and forward non-MCP-UI messages to the original postMessage + */ + patchPostMessage() { + this.originalPostMessage = + window.parent?.postMessage?.bind(window.parent) || null + if (!this.originalPostMessage) { + this.config.logger.debug( + '[MCPUI-Apps SDK Adapter] parent.postMessage does not exist, installing shim only', + ) + } else { + this.config.logger.debug( + '[MCPUI-Apps SDK Adapter] Monkey-patching parent.postMessage to intercept MCP-UI messages', + ) + } + const postMessageInterceptor = (message, targetOrigin, transfer) => { + if (this.isMCPUIMessage(message)) { + this.config.logger.debug( + '[MCPUI-Apps SDK Adapter] Intercepted MCP-UI message:', + message.type, + ) + this.handleMCPUIMessage(message) + } else { + if (this.originalPostMessage) { + this.config.logger.debug( + '[MCPUI-Apps SDK Adapter] Forwarding non-MCP-UI message to original postMessage', + ) + this.originalPostMessage(message, targetOrigin, transfer) + } else { + this.config.logger.warn( + '[MCPUI-Apps SDK Adapter] No original postMessage to forward to, ignoring message:', + message, + ) + } + } + } + try { + window.parent.postMessage = postMessageInterceptor + } catch (error) { + this.config.logger.error( + '[MCPUI-Apps SDK Adapter] Failed to monkey-patch parent.postMessage:', + error, + ) + } + } + /** + * Check if a message is an MCP-UI protocol message + */ + isMCPUIMessage(message) { + if (!message || typeof message !== 'object') { + return false + } + const msg = message + return ( + typeof msg.type === 'string' && + (msg.type.startsWith('ui-') || + ['tool', 'prompt', 'intent', 'notify', 'link'].includes(msg.type)) + ) + } + /** + * Handle incoming MCP-UI messages and translate to Apps SDK actions + */ + async handleMCPUIMessage(message) { + this.config.logger.debug( + '[MCPUI-Apps SDK Adapter] Received MCPUI message:', + message.type, + ) + try { + switch (message.type) { + case 'tool': + await this.handleToolMessage(message) + break + case 'prompt': + await this.handlePromptMessage(message) + break + case 'intent': + await this.handleIntentMessage(message) + break + case 'notify': + await this.handleNotifyMessage(message) + break + case 'link': + await this.handleLinkMessage(message) + break + case 'ui-lifecycle-iframe-ready': + this.sendRenderData() + break + case 'ui-request-render-data': + this.sendRenderData(message.messageId) + break + case 'ui-size-change': + this.handleSizeChange(message) + break + case 'ui-request-data': + this.handleRequestData(message) + break + default: + this.config.logger.warn( + '[MCPUI-Apps SDK Adapter] Unknown message type:', + message.type, + ) + } + } catch (error) { + this.config.logger.error( + '[MCPUI-Apps SDK Adapter] Error handling message:', + error, + ) + if (message.messageId) { + this.sendErrorResponse(message.messageId, error) + } + } + } + /** + * Handle 'tool' message - call Apps SDK tool + */ + async handleToolMessage(message) { + if (message.type !== 'tool') return + const { toolName, params } = message.payload + const messageId = message.messageId || this.generateMessageId() + this.sendAcknowledgment(messageId) + try { + if (!window.openai?.callTool) { + throw new Error('Tool calling is not supported in this environment') + } + const result = await this.withTimeout( + window.openai.callTool(toolName, params), + messageId, + ) + this.sendSuccessResponse(messageId, result) + } catch (error) { + this.sendErrorResponse(messageId, error) + } + } + /** + * Handle 'prompt' message - send followup turn + */ + async handlePromptMessage(message) { + if (message.type !== 'prompt') return + const prompt = message.payload.prompt + const messageId = message.messageId || this.generateMessageId() + this.sendAcknowledgment(messageId) + try { + if (!window.openai?.sendFollowUpMessage) { + throw new Error('Followup turns are not supported in this environment') + } + await this.withTimeout( + window.openai.sendFollowUpMessage({ prompt }), + messageId, + ) + this.sendSuccessResponse(messageId, { success: true }) + } catch (error) { + this.sendErrorResponse(messageId, error) + } + } + /** + * Handle 'intent' message - convert to prompt or ignore based on config + */ + async handleIntentMessage(message) { + if (message.type !== 'intent') return + const messageId = message.messageId || this.generateMessageId() + this.sendAcknowledgment(messageId) + if (this.config.intentHandling === 'ignore') { + this.config.logger.log( + '[MCPUI-Apps SDK Adapter] Intent ignored:', + message.payload.intent, + ) + this.sendSuccessResponse(messageId, { ignored: true }) + return + } + const { intent, params } = message.payload + const prompt = `${intent}${params ? ': ' + JSON.stringify(params) : ''}` + try { + if (!window.openai?.sendFollowUpMessage) { + throw new Error('Followup turns are not supported in this environment') + } + await this.withTimeout( + window.openai.sendFollowUpMessage({ prompt }), + messageId, + ) + this.sendSuccessResponse(messageId, { success: true }) + } catch (error) { + this.sendErrorResponse(messageId, error) + } + } + /** + * Handle 'notify' message - log only + */ + async handleNotifyMessage(message) { + if (message.type !== 'notify') return + const messageId = message.messageId || this.generateMessageId() + this.config.logger.log( + '[MCPUI-Apps SDK Adapter] Notification:', + message.payload.message, + ) + this.sendAcknowledgment(messageId) + this.sendSuccessResponse(messageId, { acknowledged: true }) + } + /** + * Handle 'link' message - not supported in Apps SDK environments + */ + async handleLinkMessage(message) { + if (message.type !== 'link') return + const messageId = message.messageId || this.generateMessageId() + this.sendAcknowledgment(messageId) + this.sendErrorResponse( + messageId, + new Error('Navigation is not supported in Apps SDK environment'), + ) + } + /** + * Handle size change - no-op in Apps SDK environment + */ + handleSizeChange(message) { + this.config.logger.debug( + '[MCPUI-Apps SDK Adapter] Size change requested (no-op in Apps SDK):', + message.payload, + ) + } + /** + * Handle generic data request + */ + handleRequestData(message) { + const messageId = message.messageId || this.generateMessageId() + this.sendAcknowledgment(messageId) + this.sendErrorResponse( + messageId, + new Error('Generic data requests not yet implemented'), + ) + } + /** + * Setup listeners for Apps SDK events + */ + setupAppsSdkEventListeners() { + window.addEventListener('openai:set_globals', () => { + this.config.logger.debug('[MCPUI-Apps SDK Adapter] Globals updated') + this.sendRenderData() + }) + } + /** + * Gather render data from Apps SDK and send to widget + */ + sendRenderData(requestMessageId) { + if (!window.openai) return + const renderData = { + toolInput: window.openai.toolInput, + toolOutput: window.openai.toolOutput, + widgetState: window.openai.widgetState, + locale: window.openai.locale || 'en-US', + theme: window.openai.theme || 'light', + displayMode: window.openai.displayMode || 'inline', + maxHeight: window.openai.maxHeight, + } + this.dispatchMessageToIframe({ + type: 'ui-lifecycle-iframe-render-data', + messageId: requestMessageId, + payload: { renderData }, + }) + } + /** + * Send acknowledgment for a message + */ + sendAcknowledgment(messageId) { + this.dispatchMessageToIframe({ + type: 'ui-message-received', + payload: { messageId }, + }) + } + /** + * Send success response + */ + sendSuccessResponse(messageId, response) { + this.dispatchMessageToIframe({ + type: 'ui-message-response', + payload: { messageId, response }, + }) + } + /** + * Send error response + */ + sendErrorResponse(messageId, error) { + const errorObj = + error instanceof Error + ? { message: error.message, name: error.name } + : { message: String(error) } + this.dispatchMessageToIframe({ + type: 'ui-message-response', + payload: { messageId, error: errorObj }, + }) + } + /** + * Dispatch a MessageEvent to the iframe (widget) + * Simulates messages that would normally come from the parent/host + */ + dispatchMessageToIframe(data) { + const event = new MessageEvent('message', { + data, + origin: this.config.hostOrigin, + source: null, + }) + window.dispatchEvent(event) + } + /** + * Wrap a promise with timeout + */ + async withTimeout(promise, requestId) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingRequests.delete(requestId) + reject(new Error(`Request timed out after ${this.config.timeout}ms`)) + }, this.config.timeout) + this.pendingRequests.set(requestId, { + messageId: requestId, + type: 'generic', + resolve, + reject, + timeoutId, + }) + promise + .then((result) => { + clearTimeout(timeoutId) + this.pendingRequests.delete(requestId) + resolve(result) + }) + .catch((error) => { + clearTimeout(timeoutId) + this.pendingRequests.delete(requestId) + reject(error) + }) + }) + } + /** + * Generate a unique message ID + */ + generateMessageId() { + return `adapter-${Date.now()}-${++this.messageIdCounter}` + } +} +var adapterInstance = null +function initAdapter(config) { + if (adapterInstance) { + console.warn('[MCPUI-Apps SDK Adapter] Adapter already initialized') + return true + } + adapterInstance = new MCPUIAppsSdkAdapter(config) + return adapterInstance.install() +} +function uninstallAdapter() { + if (adapterInstance) { + adapterInstance.uninstall() + adapterInstance = null + } +} +if ( + typeof window !== 'undefined' && + !window.MCP_APPSSDK_ADAPTER_NO_AUTO_INSTALL +) { + initAdapter() +} diff --git a/app/utils/mcp.ts b/app/utils/mcp.ts index 823dc0f..b2b1624 100644 --- a/app/utils/mcp.ts +++ b/app/utils/mcp.ts @@ -1,3 +1,5 @@ +import '#app/utils/mcp-ui-compat.client.ts' + import { useEffect } from 'react' import { type z } from 'zod' @@ -56,16 +58,6 @@ function sendMcpMessage( payload: McpMessageTypes[TypeType], options: MessageOptions = {}, ): McpMessageReturnType { - debugger - // if (type === 'tool') { - // // Goose does not currentlly support tool calls, so change this to a prompt - // const { toolName, params } = payload as McpMessageTypes['tool'] - // type = 'prompt' as TypeType - // payload = { - // prompt: `Please call the tool ${toolName} with the following parameters: ${JSON.stringify(params)}`, - // } as McpMessageTypes[TypeType] - // } - const { signal: givenSignal, schema, timeoutMs = 3_000 } = options const timeoutSignal = typeof timeoutMs === 'number' ? AbortSignal.timeout(timeoutMs) : undefined @@ -91,6 +83,20 @@ function sendMcpMessage( } console.log('posting to parent', { type, messageId, payload }) + if (type === 'tool') { + return resolve( + // @ts-expect-error ... we'll make this great eventually + window.openai.callTool(payload.toolName, payload.params).then((r) => { + // @ts-expect-error ... we'll make this great eventually + void window.openai.sendFollowUpMessage({ + // @ts-expect-error ... we'll make this great eventually + prompt: `I have called ${payload.toolName} with params ${JSON.stringify(payload.params)} and got the following response: ${JSON.stringify(r)}`, + }) + return r + }), + ) + } + window.parent.postMessage({ type, messageId, payload }, '*') function handleMessage(event: MessageEvent) { @@ -121,68 +127,42 @@ function sendMcpMessage( export { sendMcpMessage } -// Module-level queue for render data events -const renderDataQueue: Array<{ type: string; payload: any }> = [] - -// Set up global listener immediately when module loads (only in the client) -if (typeof document !== 'undefined') { - window.addEventListener('message', (event) => { - if (event.data?.type === 'ui-lifecycle-iframe-render-data') { - renderDataQueue.push(event.data) - } - }) -} - export function waitForRenderData( schema: z.ZodSchema, - opts: { signal?: AbortSignal; timeoutMs?: number } = {}, ): Promise { - const { signal: givenSignal, timeoutMs = 3_000 } = opts - const timeoutSignal = - typeof timeoutMs === 'number' ? AbortSignal.timeout(timeoutMs) : undefined - - const signals = [givenSignal, timeoutSignal].filter(Boolean) - const signal = AbortSignal.any(signals) - - return new Promise((resolve, reject) => { - // Check if we already received the data - const queuedEvent = renderDataQueue.find( - (event) => event.type === 'ui-lifecycle-iframe-render-data', - ) - if (queuedEvent) { - const result = schema.safeParse(queuedEvent.payload.renderData) - return result.success ? resolve(result.data) : reject(result.error) + let toolOutput = window.openai?.toolOutput + if (toolOutput) { + const parseResult = schema.safeParse({ toolOutput }) + if (parseResult.success) { + return Promise.resolve(parseResult.data) } - - // Otherwise, set up the normal listening logic - - function cleanup() { - window.removeEventListener('message', handleMessage) - signal.removeEventListener?.('abort', onAbort as EventListener) - } - - function onAbort() { - cleanup() - const reason = - (signal as any).reason ?? - new DOMException('Timed out waiting for render data', 'TimeoutError') - reject(reason) - } - - function handleMessage(event: MessageEvent) { - if (event.data?.type !== 'ui-lifecycle-iframe-render-data') return - - const result = schema.safeParse(event.data.payload) - cleanup() - return result.success ? resolve(result.data) : reject(result.error) - } - - signal.addEventListener('abort', onAbort, { once: true }) - window.addEventListener('message', handleMessage, { - once: true, - signal, + throw new Error(parseResult.error.message) + } + + return new Promise((resolve, reject) => { + Object.defineProperty(window.openai, 'toolOutput', { + get() { + return toolOutput + }, + set(newValue: any) { + toolOutput = newValue + const parseResult = schema.safeParse({ toolOutput }) + if (parseResult.success) { + resolve(parseResult.data) + } else { + reject(new Error(parseResult.error.message)) + } + }, + configurable: true, + enumerable: true, }) - - if (signal.aborted) onAbort() }) } + +declare global { + interface Window { + openai?: { + toolOutput?: any + } + } +} diff --git a/package-lock.json b/package-lock.json index 086283b..040b7ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,25 +12,26 @@ "@epic-web/invariant": "^1.0.0", "@epic-web/totp": "^4.0.1", "@mcp-ui/server": "^5.11.0", - "@modelcontextprotocol/sdk": "^1.19.1", + "@modelcontextprotocol/sdk": "^1.20.0", "agents": "https://pkg.pr.new/cloudflare/agents@485", "isbot": "^5.1.31", "react": "^19.2.0", "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", - "react-router": "^7.9.3", + "react-router": "^7.9.4", "zod": "^3.25.67" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.13.10", + "@cloudflare/vite-plugin": "^1.13.12", "@epic-web/config": "^1.21.3", "@modelcontextprotocol/inspector": "^0.17.0", - "@react-router/dev": "^7.9.3", + "@react-router/dev": "^7.9.4", "@tailwindcss/vite": "^4.1.14", - "@types/node": "^24.6.2", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "eslint": "^9.36.0", + "@types/node": "^24.7.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.1", + "cross-env": "^10.1.0", + "eslint": "^9.37.0", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4.1.14", @@ -38,7 +39,7 @@ "vite": "^7.1.9", "vite-plugin-devtools-json": "^1.0.0", "vite-tsconfig-paths": "^5.1.4", - "wrangler": "^4.42.0" + "wrangler": "^4.42.2" } }, "node_modules/@adraffy/ens-normalize": { @@ -704,9 +705,9 @@ } }, "node_modules/@cloudflare/unenv-preset": { - "version": "2.7.6", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.7.6.tgz", - "integrity": "sha512-ykG2nd3trk6jbknRCH69xL3RpGLLbKCrbTbWSOvKEq7s4jH06yLrQlRr/q9IU+dK9p1JY1EXqhFK7VG5KqhzmQ==", + "version": "2.7.7", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.7.7.tgz", + "integrity": "sha512-HtZuh166y0Olbj9bqqySckz0Rw9uHjggJeoGbDx5x+sgezBXlxO6tQSig2RZw5tgObF8mWI8zaPvQMkQZtAODw==", "dev": true, "license": "MIT OR Apache-2.0", "peerDependencies": { @@ -720,25 +721,25 @@ } }, "node_modules/@cloudflare/vite-plugin": { - "version": "1.13.10", - "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.13.10.tgz", - "integrity": "sha512-zQaCbzGDAMhjZqXfulpUgBL/D4qsoP1oHVk2LyseKJ47PMq2cHWnbISOi3RONvKxpGyct7ACjA4JEhbFlu5GNQ==", + "version": "1.13.12", + "resolved": "https://registry.npmjs.org/@cloudflare/vite-plugin/-/vite-plugin-1.13.12.tgz", + "integrity": "sha512-JEcrUF1uXxMQfp+RcGw7ov5K8uOGX0arFYdyO3QfHrjWspHnsw0zOYL+4083itEInRVnZjS5LunpsNpxSVbCEw==", "dev": true, "license": "MIT", "dependencies": { - "@cloudflare/unenv-preset": "2.7.6", + "@cloudflare/unenv-preset": "2.7.7", "@remix-run/node-fetch-server": "^0.8.0", "get-port": "^7.1.0", - "miniflare": "4.20251001.0", + "miniflare": "4.20251008.0", "picocolors": "^1.1.1", "tinyglobby": "^0.2.12", "unenv": "2.0.0-rc.21", - "wrangler": "4.42.0", + "wrangler": "4.42.2", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0", - "wrangler": "^4.42.0" + "wrangler": "^4.42.2" } }, "node_modules/@cloudflare/vite-plugin/node_modules/ws": { @@ -764,9 +765,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20251001.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20251001.0.tgz", - "integrity": "sha512-y1ST/cCscaRewWRnsHZdWbgiLJbki5UMGd0hMo/FLqjlztwPeDgQ5CGm5jMiCDdw/IBCpWxEukftPYR34rWNog==", + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20251008.0.tgz", + "integrity": "sha512-yph0H+8mMOK5Z9oDwjb8rI96oTVt4no5lZ43aorcbzsWG9VUIaXSXlBBoB3von6p4YCRW+J3n36fBM9XZ6TLaA==", "cpu": [ "x64" ], @@ -781,9 +782,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20251001.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20251001.0.tgz", - "integrity": "sha512-+z4QHHZ/Yix82zLFYS+ZS2UV09IENFPwDCEKUWfnrM9Km2jOOW3Ua4hJNob1EgQUYs8fFZo7k5O/tpwxMsSbbQ==", + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20251008.0.tgz", + "integrity": "sha512-Yc4lMGSbM4AEtYRpyDpmk77MsHb6X2BSwJgMgGsLVPmckM7ZHivZkJChfcNQjZ/MGR6nkhYc4iF6TcVS+UMEVw==", "cpu": [ "arm64" ], @@ -798,9 +799,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20251001.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20251001.0.tgz", - "integrity": "sha512-hGS+O2V9Mm2XjJUaB9ZHMA5asDUaDjKko42e+accbew0PQR7zrAl1afdII6hMqCLV4tk4GAjvhv281pN4g48rg==", + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20251008.0.tgz", + "integrity": "sha512-AjoQnylw4/5G6SmfhZRsli7EuIK7ZMhmbxtU0jkpciTlVV8H01OsFOgS1d8zaTXMfkWamEfMouy8oH/L7B9YcQ==", "cpu": [ "x64" ], @@ -815,9 +816,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20251001.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20251001.0.tgz", - "integrity": "sha512-QYaMK+pRgt28N7CX1JlJ+ToegJF9LxzqdT7MjWqPgVj9D2WTyIhBVYl3wYjJRcgOlnn+DRt42+li4T64CPEeuA==", + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20251008.0.tgz", + "integrity": "sha512-hRy9yyvzVq1HsqHZUmFkAr0C8JGjAD/PeeVEGCKL3jln3M9sNCKIrbDXiL+efe+EwajJNNlDxpO+s30uVWVaRg==", "cpu": [ "arm64" ], @@ -832,9 +833,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20251001.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20251001.0.tgz", - "integrity": "sha512-ospnDR/FlyRvrv9DSHuxDAXmzEBLDUiAHQrQHda1iUH9HqxnNQ8giz9VlPfq7NIRc7bQ1ZdIYPGLJOY4Q366Ng==", + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20251008.0.tgz", + "integrity": "sha512-Gm0RR+ehfNMsScn2pUcn3N9PDUpy7FyvV9ecHEyclKttvztyFOcmsF14bxEaSVv7iM4TxWEBn1rclmYHxDM4ow==", "cpu": [ "x64" ], @@ -1527,19 +1528,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", - "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", + "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1611,9 +1615,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", - "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", + "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", "dev": true, "license": "MIT", "engines": { @@ -1634,13 +1638,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.2", + "@eslint/core": "^0.16.0", "levn": "^0.4.1" }, "engines": { @@ -3100,9 +3104,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.19.1.tgz", - "integrity": "sha512-3Y2h3MZKjec1eAqSTBclATlX+AbC6n1LgfVzRMJLt3v6w0RCYgwLrjbxPDbhsYHt6Wdqc/aCceNJYgj448ELQQ==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.0.tgz", + "integrity": "sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -4238,9 +4242,9 @@ "license": "MIT" }, "node_modules/@react-router/dev": { - "version": "7.9.3", - "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.9.3.tgz", - "integrity": "sha512-oPaO+OpvCo/rNTJrRipHSp31/K4It19PE5A24x21FlYlemPTe3fbGX/kyC2+8au/abXbvzNHfRbuIBD/rfojmA==", + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.9.4.tgz", + "integrity": "sha512-bLs6DjKMJExT7Y57EBx25hkeGGUla3pURxvOn15IN8Mmaw2+euDtBUX9+OFrAPsAzD1xIj6+2HNLXlFH/LB86Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4252,7 +4256,7 @@ "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", - "@react-router/node": "7.9.3", + "@react-router/node": "7.9.4", "@remix-run/node-fetch-server": "^0.9.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", @@ -4269,7 +4273,7 @@ "react-refresh": "^0.14.0", "semver": "^7.3.7", "tinyglobby": "^0.2.14", - "valibot": "^0.41.0", + "valibot": "^1.1.0", "vite-node": "^3.2.2" }, "bin": { @@ -4279,9 +4283,9 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@react-router/serve": "^7.9.3", + "@react-router/serve": "^7.9.4", "@vitejs/plugin-rsc": "*", - "react-router": "^7.9.3", + "react-router": "^7.9.4", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" @@ -4323,9 +4327,9 @@ "license": "MIT" }, "node_modules/@react-router/node": { - "version": "7.9.3", - "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.9.3.tgz", - "integrity": "sha512-+OvWxPPUgouOshw85QlG0J6yFJM0GMCCpXqPj38IcveeFLlP7ppOAEkOi7RBFrDvg7vSUtCEBDnsbuDCvxUPJg==", + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.9.4.tgz", + "integrity": "sha512-sdeDNRaqAB71BR2hPlhcQbPbrXh8uGJUjLVc+NpRiPsQbv6B8UvIucN4IX9YGVJkw3UxVQBn2vPSwxACAck32Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4335,7 +4339,7 @@ "node": ">=20.0.0" }, "peerDependencies": { - "react-router": "7.9.3", + "react-router": "7.9.4", "typescript": "^5.1.0" }, "peerDependenciesMeta": { @@ -6793,19 +6797,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.6.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.2.tgz", - "integrity": "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==", + "version": "24.7.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz", + "integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.13.0" + "undici-types": "~7.14.0" } }, "node_modules/@types/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz", - "integrity": "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "dev": true, "license": "MIT", "dependencies": { @@ -6813,9 +6817,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==", + "version": "19.2.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz", + "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", "dev": true, "license": "MIT", "peerDependencies": { @@ -9100,6 +9104,24 @@ "node": ">=18" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -9908,20 +9930,20 @@ } }, "node_modules/eslint": { - "version": "9.36.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", - "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "version": "9.37.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", + "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.1", - "@eslint/core": "^0.15.2", + "@eslint/config-helpers": "^0.4.0", + "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.36.0", - "@eslint/plugin-kit": "^0.3.5", + "@eslint/js": "9.37.0", + "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -12652,9 +12674,9 @@ } }, "node_modules/miniflare": { - "version": "4.20251001.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20251001.0.tgz", - "integrity": "sha512-OHd31D2LT8JH+85nVXClV0Z18jxirCohzKNAcZs/fgt4mIkUDtidX3VqR3ovAM0jWooNxrFhB9NSs3iDbiJF7Q==", + "version": "4.20251008.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20251008.0.tgz", + "integrity": "sha512-sKCNYNzXG6l8qg0Oo7y8WcDKcpbgw0qwZsxNpdZilFTR4EavRow2TlcwuPSVN99jqAjhz0M4VXvTdSGdtJ2VfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12666,7 +12688,7 @@ "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", - "workerd": "1.20251001.0", + "workerd": "1.20251008.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" @@ -14174,9 +14196,9 @@ } }, "node_modules/react-router": { - "version": "7.9.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", - "integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==", + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", + "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -15890,9 +15912,9 @@ } }, "node_modules/undici-types": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", - "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", "license": "MIT" }, "node_modules/unenv": { @@ -16203,9 +16225,9 @@ "license": "MIT" }, "node_modules/valibot": { - "version": "0.41.0", - "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.41.0.tgz", - "integrity": "sha512-igDBb8CTYr8YTQlOKgaN9nSS0Be7z+WRuaeYqGf3Cjz3aKmSnqEmYnkfVjzIuumGqfHpa3fLIvMEAfhrpqN8ng==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.1.0.tgz", + "integrity": "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -16651,9 +16673,9 @@ } }, "node_modules/workerd": { - "version": "1.20251001.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20251001.0.tgz", - "integrity": "sha512-oT/K4YWNhmwpVmGeaHNmF7mLRfgjszlVr7lJtpS4jx5khmxmMzWZEEQRrJEpgzeHP6DOq9qWLPNT0bjMK7TchQ==", + "version": "1.20251008.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20251008.0.tgz", + "integrity": "sha512-HwaJmXO3M1r4S8x2ea2vy8Rw/y/38HRQuK/gNDRQ7w9cJXn6xSl1sIIqKCffULSUjul3wV3I3Nd/GfbmsRReEA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -16664,28 +16686,28 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20251001.0", - "@cloudflare/workerd-darwin-arm64": "1.20251001.0", - "@cloudflare/workerd-linux-64": "1.20251001.0", - "@cloudflare/workerd-linux-arm64": "1.20251001.0", - "@cloudflare/workerd-windows-64": "1.20251001.0" + "@cloudflare/workerd-darwin-64": "1.20251008.0", + "@cloudflare/workerd-darwin-arm64": "1.20251008.0", + "@cloudflare/workerd-linux-64": "1.20251008.0", + "@cloudflare/workerd-linux-arm64": "1.20251008.0", + "@cloudflare/workerd-windows-64": "1.20251008.0" } }, "node_modules/wrangler": { - "version": "4.42.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.42.0.tgz", - "integrity": "sha512-OZXiUSfGD66OVkncDbjZtqrsH6bWPRQMYc6RmMbkzYm/lEvJ8lvARKcqDgEyq8zDAgJAivlMQLyPtKQoVjQ/4g==", + "version": "4.42.2", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.42.2.tgz", + "integrity": "sha512-1iTnbjB4F12KSP1zbfxQL495xarS+vdrZnulQP2SEcAxDTUGn7N9zk1O2WtFOc+Fhcgl+9/sdz/4AL9pF34Pwg==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", - "@cloudflare/unenv-preset": "2.7.6", + "@cloudflare/unenv-preset": "2.7.7", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", - "miniflare": "4.20251001.0", + "miniflare": "4.20251008.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.21", - "workerd": "1.20251001.0" + "workerd": "1.20251008.0" }, "bin": { "wrangler": "bin/wrangler.js", @@ -16698,7 +16720,7 @@ "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20251001.0" + "@cloudflare/workers-types": "^4.20251008.0" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index ad0a755..e827329 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ }, "scripts": { "build": "react-router build", + "build:staging": "cross-env CLOUDFLARE_ENV=staging react-router build", "deploy": "npm run build && wrangler deploy", + "deploy:staging": "npm run build:staging && wrangler deploy", "dev": "react-router dev", "format": "prettier --write .", "lint": "eslint .", @@ -25,25 +27,26 @@ "@epic-web/invariant": "^1.0.0", "@epic-web/totp": "^4.0.1", "@mcp-ui/server": "^5.11.0", - "@modelcontextprotocol/sdk": "^1.19.1", + "@modelcontextprotocol/sdk": "^1.20.0", "agents": "https://pkg.pr.new/cloudflare/agents@485", "isbot": "^5.1.31", "react": "^19.2.0", "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", - "react-router": "^7.9.3", + "react-router": "^7.9.4", "zod": "^3.25.67" }, "devDependencies": { - "@cloudflare/vite-plugin": "^1.13.10", + "@cloudflare/vite-plugin": "^1.13.12", "@epic-web/config": "^1.21.3", "@modelcontextprotocol/inspector": "^0.17.0", - "@react-router/dev": "^7.9.3", + "@react-router/dev": "^7.9.4", "@tailwindcss/vite": "^4.1.14", - "@types/node": "^24.6.2", - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", - "eslint": "^9.36.0", + "@types/node": "^24.7.1", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.1", + "cross-env": "^10.1.0", + "eslint": "^9.37.0", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4.1.14", @@ -51,7 +54,7 @@ "vite": "^7.1.9", "vite-plugin-devtools-json": "^1.0.0", "vite-tsconfig-paths": "^5.1.4", - "wrangler": "^4.42.0" + "wrangler": "^4.42.2" }, "prettier": "@epic-web/config/prettier", "license": "GPL-3.0-only" diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..53c121e --- /dev/null +++ b/public/_headers @@ -0,0 +1,5 @@ +/* + Access-Control-Allow-Origin: * + Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS + Access-Control-Allow-Headers: * + Access-Control-Max-Age: 86400 diff --git a/react-router.config.ts b/react-router.config.ts index d115e54..8160f32 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -2,6 +2,7 @@ import { type Config } from '@react-router/dev/config' export default { ssr: true, + routeDiscovery: { mode: 'initial' }, future: { unstable_viteEnvironmentApi: true, }, diff --git a/types/worker-configuration.d.ts b/types/worker-configuration.d.ts index 869bd42..9b76c61 100644 --- a/types/worker-configuration.d.ts +++ b/types/worker-configuration.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ // Generated by Wrangler by running `wrangler types ./types/worker-configuration.d.ts` (hash: 808cf267dca29f8c048696dc60b21f13) -// Runtime types generated with workerd@1.20251001.0 2025-04-17 nodejs_compat +// Runtime types generated with workerd@1.20251008.0 2025-04-17 nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("../worker/index"); @@ -7437,6 +7437,10 @@ type MediaTransformationOutputOptions = { * Duration for video clips, audio extraction, and spritesheet generation (e.g. '5s'). */ duration?: string; + /** + * Number of frames in the spritesheet. + */ + imageCount?: number; /** * Output format for the generated media. */ diff --git a/vite.config.ts b/vite.config.ts index 5b43f04..099e46b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,13 @@ import devtoolsJson from 'vite-plugin-devtools-json' import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ + base: + process.env.NODE_ENV === 'production' + ? process.env.CLOUDFLARE_ENV === 'staging' + ? 'https://epic-me-mcp-staging.kentcdodds.workers.dev/' + : 'https://epic-me-mcp.kentcdodds.workers.dev/' + : undefined, + define: { BUILD_TIMESTAMP: JSON.stringify(Date.now()) }, server: { port: 8877, }, @@ -20,7 +27,10 @@ export default defineConfig({ if (id.includes('+types/')) return 'export {}' }, }, - cloudflare({ viteEnvironment: { name: 'ssr' } }), + cloudflare({ + viteEnvironment: { name: 'ssr' }, + experimental: { headersAndRedirectsDevModeSupport: true }, + }), tailwindcss(), reactRouter(), tsconfigPaths(), diff --git a/worker/mcp/index.ts b/worker/mcp/index.ts index a707af2..5e45b9e 100644 --- a/worker/mcp/index.ts +++ b/worker/mcp/index.ts @@ -12,6 +12,7 @@ import { DB } from '../db' import { initializePrompts } from './prompts.ts' import { initializeResources } from './resources.ts' import { initializeTools } from './tools.ts' +import { registerWidgets } from './widgets.ts' type State = {} export type Props = { grantId: string; grantUserId: string; baseUrl: string } @@ -92,6 +93,7 @@ Always call \`whoami\` first. If unauthenticated: 1) \`authenticate\` with email await initializeTools(this) await initializeResources(this) await initializePrompts(this) + await registerWidgets(this) } onStateUpdate(state: State | undefined, source: Connection | 'server') { @@ -134,6 +136,9 @@ Always call \`whoami\` first. If unauthenticated: 1) \`authenticate\` with email } async updateAvailableItems() { + const supportsListUpdates = false + if (supportsListUpdates) return + const { grantId } = this.props ?? {} if (!grantId) return diff --git a/worker/mcp/tools.ts b/worker/mcp/tools.ts index 12079c8..e0047aa 100644 --- a/worker/mcp/tools.ts +++ b/worker/mcp/tools.ts @@ -107,77 +107,77 @@ export async function initializeTools(agent: EpicMeMCP) { ) agent.authenticatedTools.push( - agent.server.registerTool( - 'view_journal', - { - title: 'View Journal', - description: - 'View your journal entries in a beautiful, scrollable interface', - annotations: { - readOnlyHint: true, - openWorldHint: false, - } satisfies ToolAnnotations, - }, - async () => { - const user = await agent.requireUser() - const baseUrl = agent.requireDomain() - const uiUrl = new URL(`/ui/journal-viewer`, baseUrl) - const entries = await agent.db.getEntries(user.id) - return { - content: [ - createText( - `Here's your journal viewer. You can scroll through your entries and expand them to read the full content.`, - ), - createUIResource({ - uri: `ui://journal-viewer/${user.id}`, - content: { - type: 'externalUrl', - iframeUrl: uiUrl.toString(), - }, - encoding: 'text', - uiMetadata: { - 'initial-render-data': { entries }, - }, - }), - ], - } - }, - ), - agent.server.registerTool( - 'view_entry', - { - title: 'View Entry', - description: 'View a journal entry by ID visually', - annotations: { - readOnlyHint: true, - openWorldHint: false, - } satisfies ToolAnnotations, - inputSchema: entryIdSchema, - }, - async ({ id }) => { - const user = await agent.requireUser() - const baseUrl = agent.requireDomain() - const iframeUrl = new URL('/ui/entry-viewer', baseUrl) - const entry = await agent.db.getEntry(user.id, id) - invariant(entry, `Entry with ID "${id}" not found`) + // agent.server.registerTool( + // 'view_journal', + // { + // title: 'View Journal', + // description: + // 'View your journal entries in a beautiful, scrollable interface', + // annotations: { + // readOnlyHint: true, + // openWorldHint: false, + // } satisfies ToolAnnotations, + // }, + // async () => { + // const user = await agent.requireUser() + // const baseUrl = agent.requireDomain() + // const uiUrl = new URL(`/ui/journal-viewer`, baseUrl) + // const entries = await agent.db.getEntries(user.id) + // return { + // content: [ + // createText( + // `Here's your journal viewer. You can scroll through your entries and expand them to read the full content.`, + // ), + // createUIResource({ + // uri: `ui://journal-viewer/${user.id}`, + // content: { + // type: 'externalUrl', + // iframeUrl: uiUrl.toString(), + // }, + // encoding: 'text', + // uiMetadata: { + // 'initial-render-data': { entries }, + // }, + // }), + // ], + // } + // }, + // ), + // agent.server.registerTool( + // 'view_entry', + // { + // title: 'View Entry', + // description: 'View a journal entry by ID visually', + // annotations: { + // readOnlyHint: true, + // openWorldHint: false, + // } satisfies ToolAnnotations, + // inputSchema: entryIdSchema, + // }, + // async ({ id }) => { + // const user = await agent.requireUser() + // const baseUrl = agent.requireDomain() + // const iframeUrl = new URL('/ui/entry-viewer', baseUrl) + // const entry = await agent.db.getEntry(user.id, id) + // invariant(entry, `Entry with ID "${id}" not found`) - return { - content: [ - createUIResource({ - uri: `ui://view-entry/${id}`, - content: { - type: 'externalUrl', - iframeUrl: iframeUrl.toString(), - }, - encoding: 'text', - uiMetadata: { - 'initial-render-data': { entry }, - }, - }), - ], - } - }, - ), + // return { + // content: [ + // createUIResource({ + // uri: `ui://view-entry/${id}`, + // content: { + // type: 'externalUrl', + // iframeUrl: iframeUrl.toString(), + // }, + // encoding: 'text', + // uiMetadata: { + // 'initial-render-data': { entry }, + // }, + // }), + // ], + // } + // }, + // ), agent.server.registerTool( 'whoami', { @@ -340,39 +340,39 @@ export async function initializeTools(agent: EpicMeMCP) { }, ), - agent.server.registerTool( - 'update_entry', - { - title: 'Update Entry', - description: - 'Update a journal entry. Only provided fields will be updated.', - annotations: { - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - } satisfies ToolAnnotations, - inputSchema: updateEntryInputSchema, - outputSchema: { entry: entryWithTagsSchema }, - }, - async ({ id, ...updates }) => { - const user = await agent.requireUser() - const existingEntry = await agent.db.getEntry(user.id, id) - invariant( - existingEntry, - `Entry with ID "${id}" not found. Use list_entries to see all available entries.`, - ) - const updatedEntry = await agent.db.updateEntry(user.id, id, updates) - return { - structuredContent: { entry: updatedEntry }, - content: [ - createText( - `Entry "${updatedEntry.title}" (ID: ${id}) updated successfully`, - ), - createEntryResourceLink(updatedEntry), - ], - } - }, - ), + // agent.server.registerTool( + // 'update_entry', + // { + // title: 'Update Entry', + // description: + // 'Update a journal entry. Only provided fields will be updated.', + // annotations: { + // destructiveHint: false, + // idempotentHint: true, + // openWorldHint: false, + // } satisfies ToolAnnotations, + // inputSchema: updateEntryInputSchema, + // outputSchema: { entry: entryWithTagsSchema }, + // }, + // async ({ id, ...updates }) => { + // const user = await agent.requireUser() + // const existingEntry = await agent.db.getEntry(user.id, id) + // invariant( + // existingEntry, + // `Entry with ID "${id}" not found. Use list_entries to see all available entries.`, + // ) + // const updatedEntry = await agent.db.updateEntry(user.id, id, updates) + // return { + // structuredContent: { entry: updatedEntry }, + // content: [ + // createText( + // `Entry "${updatedEntry.title}" (ID: ${id}) updated successfully`, + // ), + // createEntryResourceLink(updatedEntry), + // ], + // } + // }, + // ), agent.server.registerTool( 'delete_entry', { @@ -521,35 +521,35 @@ export async function initializeTools(agent: EpicMeMCP) { } }, ), - agent.server.registerTool( - 'update_tag', - { - title: 'Update Tag', - description: 'Update a tag', - annotations: { - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - } satisfies ToolAnnotations, - inputSchema: updateTagInputSchema, - outputSchema: { tag: tagSchema }, - }, - async ({ id, ...updates }) => { - const user = await agent.requireUser() - const updatedTag = await agent.db.updateTag(user.id, id, updates) - const structuredContent = { tag: updatedTag } - return { - structuredContent, - content: [ - createText( - `Tag "${updatedTag.name}" (ID: ${id}) updated successfully`, - ), - createTagResourceLink(updatedTag), - createText(structuredContent), - ], - } - }, - ), + // agent.server.registerTool( + // 'update_tag', + // { + // title: 'Update Tag', + // description: 'Update a tag', + // annotations: { + // destructiveHint: false, + // idempotentHint: true, + // openWorldHint: false, + // } satisfies ToolAnnotations, + // inputSchema: updateTagInputSchema, + // outputSchema: { tag: tagSchema }, + // }, + // async ({ id, ...updates }) => { + // const user = await agent.requireUser() + // const updatedTag = await agent.db.updateTag(user.id, id, updates) + // const structuredContent = { tag: updatedTag } + // return { + // structuredContent, + // content: [ + // createText( + // `Tag "${updatedTag.name}" (ID: ${id}) updated successfully`, + // ), + // createTagResourceLink(updatedTag), + // createText(structuredContent), + // ], + // } + // }, + // ), agent.server.registerTool( 'delete_tag', { diff --git a/worker/mcp/widgets.ts b/worker/mcp/widgets.ts new file mode 100644 index 0000000..22dfd5d --- /dev/null +++ b/worker/mcp/widgets.ts @@ -0,0 +1,122 @@ +import { invariant } from '@epic-web/invariant' +import { type ZodRawShape, z } from 'zod' +import { entryWithTagsSchema } from '#worker/db/schema.ts' +import { type EpicMeMCP } from './index.ts' + +declare const BUILD_TIMESTAMP: string +const version = BUILD_TIMESTAMP + +type WidgetOutput = { + inputSchema: Input + outputSchema: Output + getStructuredContent: (args: { + [Key in keyof Input]: z.infer + }) => Promise<{ + [Key in keyof Output]: z.infer + }> +} + +type Widget = { + name: string + title: string + resultMessage: string + description?: string + invokingMessage?: string + invokedMessage?: string + widgetAccessible?: boolean + widgetPrefersBorder?: boolean + resultCanProduceWidget?: boolean +} & WidgetOutput + +function createWidget( + widget: Widget, +): Widget { + return widget +} + +export async function registerWidgets(agent: EpicMeMCP) { + const widgets = [ + createWidget({ + name: 'view_entry', + title: 'View Entry', + description: + 'Renders an interactive user interface to view a journal entry', + invokingMessage: 'Retrieving your journal entry', + invokedMessage: `Here's your journal entry`, + resultMessage: 'The journal entry has been rendered', + widgetAccessible: true, + resultCanProduceWidget: true, + inputSchema: { id: z.number().describe('The ID of the entry') }, + outputSchema: { entry: entryWithTagsSchema }, + getStructuredContent: async ({ id }) => { + const user = await agent.requireUser() + const entry = await agent.db.getEntry(user.id, id) + invariant( + entry, + `Entry with ID "${id}" not found for user with id "${user.id}"`, + ) + return { entry } + }, + }), + ] + + for (const widget of widgets) { + const baseUrl = agent.requireDomain() + const name = `${widget.name}-${version}` + const uri = `ui://widget/${name}.html` + // chat gpt hosts your app on their own server and serves it at /index.html + const url = new URL('/index.html', baseUrl) + // the version is to avoid chatgpt caching between deployments + url.searchParams.set('v', version.toString()) + + agent.server.registerResource(name, uri, {}, async () => ({ + contents: [ + { + uri, + mimeType: 'text/html+skybridge', + text: await fetch(url).then(async (res) => await res.text()), + _meta: { + 'openai/widgetDescription': widget.description, + 'openai/widgetCSP': { + connect_domains: [], + resource_domains: [baseUrl], + }, + ...(widget.widgetPrefersBorder + ? { 'openai/widgetPrefersBorder': true } + : {}), + }, + }, + ], + })) + + agent.server.registerTool( + name, + { + title: widget.title, + description: widget.description, + _meta: { + 'openai/widgetDomain': baseUrl, + 'openai/outputTemplate': uri, + 'openai/toolInvocation/invoking': widget.invokingMessage, + 'openai/toolInvocation/invoked': widget.invokedMessage, + ...(widget.resultCanProduceWidget + ? { 'openai/resultCanProduceWidget': true } + : {}), + ...(widget.widgetAccessible + ? { 'openai/widgetAccessible': true } + : {}), + }, + inputSchema: widget.inputSchema, + outputSchema: widget.outputSchema, + }, + async (args) => { + return { + content: [{ type: 'text', text: widget.resultMessage }], + structuredContent: widget.getStructuredContent + ? await widget.getStructuredContent(args) + : {}, + } + }, + ) + } +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 37ed092..bff3384 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -45,4 +45,31 @@ "binding": "ASSETS", "directory": "./public/", }, + "env": { + "staging": { + "name": "epic-me-mcp-staging", + "durable_objects": { + "bindings": [ + { + "class_name": "EpicMeMCP", + "name": "EPIC_ME_MCP_OBJECT", + }, + ], + }, + "d1_databases": [ + { + "binding": "EPIC_ME_DB", + "database_name": "epic-me-staging", + "database_id": "43f17b48-22da-4de1-a1fe-3273956678d7", + "migrations_dir": "src/db/migrations", + }, + ], + "kv_namespaces": [ + { + "binding": "OAUTH_KV", + "id": "ac35c0d1f253445788ded69b3201effd", + }, + ], + }, + }, }