Skip to content

MCP Apps SDK Integration Analysis

The rover-mcp service already uses the mark3labs/mcp-go SDK (v0.31.0), which fully supports the OpenAI Apps SDK requirements. The Go SDK provides:

  1. CallToolResult with embedded Result.Meta field for _meta responses
  2. AddResource() and AddResourceTemplate() methods for widget serving
  3. Resource handlers via ResourceHandlerFunc

Key Finding: We can keep the Go implementation - we just need to add resource handlers and enhance tool responses with _meta fields.

FeaturePizzaz (TypeScript)rover-mcp (Go)Status
MCP Protocol@modelcontextprotocol/sdk v0.5.0mark3labs/mcp-go v0.31.0✅ Compatible
Tool Registrationserver.setRequestHandler(ListToolsRequestSchema)srv.server.AddTool()✅ Implemented
Tool Executionserver.setRequestHandler(CallToolRequestSchema)Tool handler funcs✅ Implemented
HTTP TransportSSE over HTTPHTTP with auth✅ Implemented
Health EndpointNot shown/health✅ Implemented
FeaturePizzaz (TypeScript)rover-mcp (Go)Status
Resource Listingserver.setRequestHandler(ListResourcesRequestSchema)Need to add❌ Missing
Resource Readingserver.setRequestHandler(ReadResourceRequestSchema)Need to add❌ Missing
Resource Templatesserver.setRequestHandler(ListResourceTemplatesRequestSchema)Need to add❌ Missing
_meta in ResponsesIncluded in tool resultsNeed to add❌ Missing
structuredContentReturned from toolsNeed to add⚠️ Partial
// pizzaz_server_node/src/server.ts:213-224
return {
content: [{ type: "text", text: widget.responseText }],
structuredContent: { pizzaTopping: args.pizzaTopping },
_meta: {
"openai/outputTemplate": widget.templateUri,
"openai/toolInvocation/invoking": widget.invoking,
"openai/toolInvocation/invoked": widget.invoked,
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true
}
};
// cmd/mcp-server/main.go:336
jsonBytes, err := json.Marshal(results)
return mcp.NewToolResultText(string(jsonBytes)), nil
// Proposed enhancement
return &mcp.CallToolResult{
Result: mcp.Result{
Meta: map[string]any{
"openai/outputTemplate": "ui://widget/offer-list.html",
"openai/toolInvocation/invoking": "Searching for offers...",
"openai/toolInvocation/invoked": "Found offers!",
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true,
},
},
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: fmt.Sprintf("Found %d offers matching '%s'", len(results), query),
},
},
}, nil
// pizzaz_server_node/src/server.ts:143-149
const resources: Resource[] = widgets.map((widget) => ({
uri: widget.templateUri, // "ui://widget/pizza-map.html"
name: widget.title, // "Show Pizza Map"
description: `${widget.title} widget markup`,
mimeType: "text/html+skybridge",
_meta: widgetMeta(widget)
}));
// Proposed implementation
widgets := []Widget{
{
URI: "ui://widget/offer-list.html",
Name: "Offer List",
Description: "Display offers in a card list",
MIMEType: "text/html+skybridge",
HTML: loadWidgetHTML("offer-list.html"),
},
}
for _, widget := range widgets {
srv.server.AddResource(
mcp.Resource{
URI: widget.URI,
Name: widget.Name,
Description: widget.Description,
MIMEType: widget.MIMEType,
},
func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: widget.URI,
MIMEType: widget.MIMEType,
Text: widget.HTML,
},
}, nil
},
)
}
// pizzaz_server_node/src/server.ts:177-194
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const widget = widgetsByUri.get(request.params.uri);
return {
contents: [{
uri: widget.templateUri,
mimeType: "text/html+skybridge",
text: widget.html,
_meta: widgetMeta(widget)
}]
};
});
// ResourceHandlerFunc automatically called by SDK
func makeResourceHandler(widget Widget) server.ResourceHandlerFunc {
return func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: widget.URI,
MIMEType: widget.MIMEType,
Text: widget.HTML,
},
}, nil
}
}

Current Response:

// Returns JSON array of offers
return mcp.NewToolResultText(string(jsonBytes)), nil

Proposed Enhanced Response:

return &mcp.CallToolResult{
Result: mcp.Result{
Meta: map[string]any{
"openai/outputTemplate": "ui://widget/offer-list.html",
"openai/toolInvocation/invoking": "Searching for offers...",
"openai/toolInvocation/invoked": fmt.Sprintf("Found %d offers!", len(results)),
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true,
},
},
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: fmt.Sprintf("Found %d offers matching '%s' for user %s", len(results), query, userID),
},
},
}, nil

Widget Data Structure:

{
"offers": [...],
"query": "coffee",
"userID": "123",
"totalResults": 15
}

Current Response:

return mcp.NewToolResultText(string(jsonBytes)), nil

Proposed Enhanced Response:

return &mcp.CallToolResult{
Result: mcp.Result{
Meta: map[string]any{
"openai/outputTemplate": "ui://widget/offer-map.html",
"openai/toolInvocation/invoking": "Mapping nearby offers...",
"openai/toolInvocation/invoked": "Mapped offers near you!",
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true,
},
},
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: fmt.Sprintf("Found %d retailers with matching offers nearby", len(results)),
},
},
}, nil

Widget Data Structure:

{
"retailers": [...],
"offerDetails": [...],
"userLocation": {"latitude": 37.7749, "longitude": -122.4194},
"query": "coffee"
}

3. Other Tools (llm_feedback, fetch_webpage, web_search, get_user_purchase_history, search_products)

Section titled “3. Other Tools (llm_feedback, fetch_webpage, web_search, get_user_purchase_history, search_products)”

These tools can remain text-only or optionally add widgets later:

  • llm_feedback: Keep as-is (no widget needed)
  • fetch_webpage: Could add a reader widget for formatted display
  • web_search: Could add search results widget
  • get_user_purchase_history: Could add purchase timeline widget
  • search_products: Could add product grid widget

Phase 1: Enhance Tool Responses (No Breaking Changes)

Section titled “Phase 1: Enhance Tool Responses (No Breaking Changes)”

Goal: Add _meta fields to tool responses while maintaining backward compatibility.

Changes:

  1. Update search_offers to return CallToolResult with _meta
  2. Update search_nearby_offers to return CallToolResult with _meta
  3. Define widget URIs as constants

Code Location: cmd/mcp-server/main.go

Effort: 2-3 hours

Example:

// Constants for widget URIs
const (
widgetOfferList = "ui://widget/offer-list.html"
widgetOfferMap = "ui://widget/offer-map.html"
)
// In search_offers tool handler
return &mcp.CallToolResult{
Result: mcp.Result{
Meta: map[string]any{
"openai/outputTemplate": widgetOfferList,
"openai/toolInvocation/invoking": "Searching for offers...",
"openai/toolInvocation/invoked": "Found offers!",
"openai/widgetAccessible": true,
"openai/resultCanProduceWidget": true,
},
},
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
Text: string(jsonBytes), // Keep existing JSON for backward compat
},
},
}, nil

Goal: Implement MCP resource protocol to serve widget HTML/JS/CSS.

Changes:

  1. Create internal/widgets/ package
  2. Define Widget struct and registry
  3. Implement resource handlers
  4. Add go:embed for widget files (or CDN URLs)

Code Location: New internal/widgets/widgets.go

Effort: 4-6 hours

Example:

internal/widgets/widgets.go
package widgets
import (
_ "embed"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
//go:embed html/offer-list.html
var offerListHTML string
//go:embed html/offer-map.html
var offerMapHTML string
type Widget struct {
URI string
Name string
Description string
MIMEType string
HTML string
}
var Widgets = []Widget{
{
URI: "ui://widget/offer-list.html",
Name: "Offer List",
Description: "Display offers in a card list",
MIMEType: "text/html+skybridge",
HTML: offerListHTML,
},
{
URI: "ui://widget/offer-map.html",
Name: "Offer Map",
Description: "Display offers on an interactive map",
MIMEType: "text/html+skybridge",
HTML: offerMapHTML,
},
}
func RegisterAll(srv *server.MCPServer) {
for _, widget := range Widgets {
w := widget // Capture for closure
srv.AddResource(
mcp.Resource{
URI: w.URI,
Name: w.Name,
Description: w.Description,
MIMEType: w.MIMEType,
},
func(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: w.URI,
MIMEType: w.MIMEType,
Text: w.HTML,
},
}, nil
},
)
}
}

Usage in main.go:

import "rover-mcp/internal/widgets"
// After creating MCP server
srv := NewMCPServer()
// Register all widgets as resources
widgets.RegisterAll(srv.server)
// Continue with tool registration...

Goal: Build React widgets that render offer data from window.openai.toolOutput.

Changes:

  1. Create widgets/ directory in project root
  2. Setup Vite + React build system
  3. Create offer list widget
  4. Create offer map widget
  5. Build and embed HTML/JS/CSS

Code Location: New widgets/ directory

Effort: 8-12 hours (depending on complexity)

Directory Structure:

rover-mcp/
├── widgets/
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── src/
│ │ ├── offer-list/
│ │ │ └── index.tsx
│ │ ├── offer-map/
│ │ │ └── index.tsx
│ │ ├── use-openai-global.ts
│ │ ├── use-widget-props.ts
│ │ └── types.ts
│ └── dist/ # Generated bundles
│ ├── offer-list.html
│ └── offer-map.html
└── internal/widgets/
└── html/ # Embedded widget files
├── offer-list.html (copied from dist)
└── offer-map.html (copied from dist)

Build Process:

Terminal window
cd widgets
pnpm install
pnpm run build # Outputs to dist/
# Copy to internal/widgets/html/
cp dist/*.html ../internal/widgets/html/

Example Widget (Simplified):

widgets/src/offer-list/index.tsx
import React from "react";
import { createRoot } from "react-dom/client";
interface OfferData {
offers: Array<{
id: string;
description: string;
points: number;
category: string;
}>;
query: string;
}
function OfferListWidget() {
// Access data from MCP server via window.openai
const data = window.openai?.toolOutput as OfferData;
const theme = window.openai?.theme || "light";
if (!data?.offers) {
return <div>No offers found</div>;
}
return (
<div className={`offer-list theme-${theme}`}>
<h2>Found {data.offers.length} offers for "{data.query}"</h2>
{data.offers.map((offer) => (
<div key={offer.id} className="offer-card">
<div className="offer-points">{offer.points} pts</div>
<div className="offer-description">{offer.description}</div>
</div>
))}
</div>
);
}
createRoot(document.getElementById("offer-root")).render(<OfferListWidget />);

Goal: Validate Apps SDK integration in ChatGPT developer mode.

Changes:

  1. Test with MCP Inspector locally
  2. Test with ChatGPT developer mode via ngrok
  3. Deploy to dev/stage environments
  4. Update documentation

Effort: 4-6 hours

The proposed changes are 100% backward compatible because:

  1. Existing clients that don’t support Apps SDK will:

    • Ignore the _meta field (per MCP spec)
    • Continue to receive Content[] with text/JSON
    • Work exactly as they do today
  2. Apps SDK clients will:

    • Read _meta.openai/outputTemplate
    • Request widget via ReadResource
    • Render widget with data from Content
  3. No schema changes to existing tools:

    • All input parameters stay the same
    • All output data stays the same
    • Only adding optional _meta field
AspectTypeScript (Pizzaz)Go (rover-mcp)Notes
SDK@modelcontextprotocol/sdkmark3labs/mcp-goBoth official MCP implementations
TransportSSE via SSEServerTransportHTTP via StreamableHTTPServerBoth support MCP over HTTP
TypingRuntime (TypeScript)Compile-time (Go)Go provides stronger type safety
Resource HandlerssetRequestHandler()AddResource() + handler funcSame capability, different API
Tool ResultsPlain object with _metaCallToolResult struct with Meta fieldSame wire format
Widget StorageIn-memory constantsgo:embed or CDNGo can embed at compile time
Content Type"text/html+skybridge""text/html+skybridge"Same MIME type

Pros:

  • Keep all existing Go code
  • Add _meta to 2 tools (search_offers, search_nearby_offers)
  • Add resource handlers for 2 widgets
  • No new dependencies
  • No TypeScript/Node.js required in deployment

Cons:

  • Need to build widgets separately (one-time setup)
  • Slightly more verbose syntax than TypeScript

Timeline: 2-3 days total

Pros:

  • Closer to Pizzaz example
  • TypeScript tooling for widgets

Cons:

  • Rewrite entire server (weeks of work)
  • Lose existing integrations (auth, metrics, logging)
  • Add Node.js to deployment
  • Higher operational complexity

Timeline: 3-4 weeks total

Verdict: ❌ Not recommended

  1. This Document: Review and approve approach
  2. 📝 Phase 1: Enhance tool responses (search_offers, search_nearby_offers)
  3. 🔧 Phase 2: Add resource handlers and widget registry
  4. 🎨 Phase 3: Build React widgets
  5. 🧪 Phase 4: Test in ChatGPT developer mode

The rover-mcp Go server is fully compatible with OpenAI Apps SDK. The mark3labs/mcp-go SDK provides all necessary primitives:

  • CallToolResult.Meta for _meta responses
  • AddResource() for widget serving
  • ResourceHandlerFunc for dynamic content
  • ✅ SSE/HTTP transport support

No rewrite needed - just enhance existing tools and add resource handlers for widgets!