jasich/football-mcp
If you are the rightful owner of football-mcp and would like to certify it and/or have it hosted online, please leave a comment on the right or send an email to dayong@mcphub.com.
A Rails app implementing a Model Context Protocol server for American football data, compatible with OpenAI Apps SDK.
Football MCP Server
An example Rails 8 application that demonstrates how to build a Model Context Protocol server with interactive UI components using the official Ruby MCP SDK and the OpenAI Apps SDK.
✨ OpenAI Apps SDK Example - This application highlights how to use the OpenAI Apps SDK to build out UI components for your Rails MCP server, including React widgets that render inside ChatGPT using Streamable HTTP transport and Server-Sent Events (SSE).
Features
- Streamable HTTP transport - The recommended transport for ChatGPT integration
- Server-Sent Events (SSE) - Real-time streaming of tool responses
- Session management - Proper handling of multiple concurrent clients
- React 19 widgets - Interactive UIs that render inside ChatGPT
- Component registry pattern - Reusable widget architecture for rapid development
- Upcoming schedule widget - Interactive ticket-purchase CTA powered by the OpenAI Apps SDK
- In-widget follow-ups - Live scores widget can trigger follow-up prompts (via
sendFollowUpMessage) to fetch deeper data without typing
⚠️ Production Warning
This application is NOT production-ready. It uses in-memory session storage which:
- Does not work with multiple servers or processes
- Loses all sessions on restart
- Is suitable only for development, demos, and single-server deployments
For production use, implement Redis-backed sessions or use stateless mode.
Screenshots
Live Scores Widget
The Live Scores Widget displays real-time football game scores in an interactive React component rendered inside ChatGPT.
Team Info Resource
The Team Info resource provides structured NFL team data including divisions, colors, and stadium information.
Setup
bundle install
npm install
cp .env.example .env
# Edit .env and set BASE_URL to your tunnel URL for ChatGPT testing
Start the server:
# Recommended: Start everything with one command (Rails + JS build + Cloudflare Tunnel)
# Note: Requires cloudflared to be installed and configured
bin/dev
# Alternative: For local testing only (no HTTPS tunnel, no auto-rebuild)
bin/rails server
Build React widgets manually:
# One-time build
npm run build
# Watch mode (rebuilds on changes) - not needed if using bin/dev
npm run watch
Run tests:
bin/rails test
Run linter:
rubocop
ChatGPT / OpenAI Apps SDK Integration
HTTPS Tunneling for Development
ChatGPT requires an HTTPS endpoint to connect to your local development server. You have several options:
Option 1: Cloudflare Tunnel (Recommended)
- No browser warnings or interstitials
- Works with your own domain
- Free tier available
- Install
cloudflaredand configure inProcfile.dev:tunnel: cloudflared tunnel run your-tunnel-name - More stable for long development sessions
Option 2: ngrok (Paid Plan)
- Free tier shows browser warnings that break ChatGPT integration
- Paid plans ($8+/month) remove the browser warning
- Simple setup:
ngrok http 3000 - Good for quick testing with paid account
Option 3: Other Tunneling Services
- localhost.run - SSH-based tunneling, no installation required
- Tailscale Funnel - If you use Tailscale for networking
- Bore - Rust-based open source alternative
- VS Code Port Forwarding - If using GitHub Codespaces or VS Code Remote
Important: Free ngrok accounts show an interstitial "Visit Site" warning page that prevents ChatGPT from loading your resources. You'll need either a paid ngrok account or an alternative tunneling solution.
Setting up for ChatGPT
-
Configure your Cloudflare Tunnel in
Procfile.dev:tunnel: cloudflared tunnel run your-tunnel-name -
Start with
bin/dev- this will run Rails, JS watch, and your tunnel -
Find your tunnel URL from the Cloudflare dashboard and update
.env:BASE_URL=https://your-tunnel-url.com -
Restart
bin/devfor the BASE_URL change to take effect
Adding Tools
Tools provide callable functions that clients can invoke.
Step 1: Create a new tool class in app/mcp_tools/
class MyTool < MCP::Tool
title "My Tool"
description "What this tool does"
input_schema(
type: "object",
properties: { param: { type: "string" } },
required: ["param"]
)
def self.call(param:, server_context: nil)
MCP::Tool::Response.new([{ "type" => "text", "text" => "Result: #{param}" }])
end
end
Step 2: Register the tool in app/controllers/mcp_controller.rb
Add it to the tools: array in the create_transport method:
tools: [GetLiveScoresTool, MyTool]
Current Tools
- GetLiveScoresTool (
app/mcp_tools/get_live_scores_tool.rb): Returns mock live football scores with optional league filtering. Demonstrates response formatting and data filtering patterns. - GetTeamInfoTool (
app/mcp_tools/get_team_info_tool.rb): Returns information about NFL teams including division, colors, and stadium details. Demonstrates structured data responses.
Adding Resources
Resources provide data that can be accessed by clients. They can be plain text, JSON, HTML widgets, or any other content type.
Step 1: Create a new resource class in app/mcp_resources/
class MyResource
VERSION = "v1" # Increment to v2, v3, etc. when content changes
URI = "my-resource://data?#{VERSION}"
class << self
def to_resource
MCP::Resource.new(
uri: URI,
name: "My Resource",
description: "What this resource provides",
mime_type: "text/plain"
)
end
def read
"Resource content here"
end
end
end
Step 2: Register the resource in app/controllers/mcp_controller.rb
Add MyResource.to_resource to the resources: array in create_transport:
resources: [LiveScoresWidgetResource.to_resource, MyResource.to_resource]
Add a when MyResource::URI case in the resources_read_handler block:
resources_read_handler: lambda { |uri, server_context|
case uri
when LiveScoresWidgetResource::URI
LiveScoresWidgetResource.read
when MyResource::URI
MyResource.read
else
raise MCP::Error.new(
code: MCP::JSONRPC::ErrorCodes::INVALID_PARAMS,
message: "Unknown resource URI: #{uri}"
)
end
}
Important: Resource Versioning
ChatGPT and other MCP clients cache resources aggressively. Always include a version parameter in your resource URI (e.g., ?v1, ?v2) and increment it whenever you modify the resource content:
class MyResource
VERSION = "v1" # Increment to v2, v3, etc. when content changes
URI = "my-resource://data?#{VERSION}"
end
Without versioning, clients will continue using stale cached versions indefinitely, even after server restarts.
Adding New React Widgets
Adding a new widget requires only 6 steps:
- Create the React component
- Add it to the component registry (one line in
application.js) - Create a resource class that uses the shared template
- Register the resource in the MCP controller
- Rebuild assets
- Test
No separate ERB template needed! All widgets share app/views/mcp_widgets/widget.html.erb.
Step 1: Create a new React component
Create a component file in app/javascript/components/:
// app/javascript/components/MyWidget.jsx
import React from 'react';
import { useToolOutput } from '../utils/openai-hooks';
const MyWidget = () => {
const toolOutput = useToolOutput();
if (!toolOutput) {
return <p>Waiting for data...</p>;
}
return (
<div className="font-sans p-5 bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100 min-h-screen">
<div className="max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-4">My Widget</h2>
<pre className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-auto">
{JSON.stringify(toolOutput, null, 2)}
</pre>
</div>
</div>
);
};
export default MyWidget;
Step 2: Register the component in app/javascript/application.js
Simply add your component to the COMPONENT_REGISTRY:
import MyWidget from './components/MyWidget';
const COMPONENT_REGISTRY = {
'LiveScoresWidget': LiveScoresWidget,
'MyWidget': MyWidget, // Add this line
};
Step 3: Create a resource class in app/mcp_resources/
# app/mcp_resources/my_widget_resource.rb
class MyWidgetResource
VERSION = "v1"
URI = "ui://widget/my-widget.html?#{VERSION}"
class << self
def to_resource
MCP::Resource.new(
uri: URI,
name: "My Widget",
description: "Custom widget for displaying data",
mime_type: "text/html+skybridge"
)
end
def read
ActionController::Base.render(
template: "mcp_widgets/widget", # Shared template
layout: false,
locals: {
widget_title: "My Widget",
component_name: "MyWidget" # Must match COMPONENT_REGISTRY key
}
)
end
def meta
base_url = ENV.fetch("BASE_URL", "http://localhost:3000")
{
"openai/widgetPrefersBorder" => true,
"openai/widgetDomain" => "https://chatgpt.com",
"openai/widgetCSP" => {
"connect_domains" => [ "https://chatgpt.com", base_url ],
"resource_domains" => [ base_url, "https://*.oaistatic.com" ]
}
}
end
end
end
Step 4: Register the resource in app/controllers/mcp_controller.rb
# Add to the resources: array in create_transport
resources: [
LiveScoresWidgetResource.to_resource,
MyWidgetResource.to_resource
]
# Add to the resources_read_handler block
resources_read_handler: lambda { |uri, server_context|
case uri
when LiveScoresWidgetResource::URI
LiveScoresWidgetResource.read
when MyWidgetResource::URI
MyWidgetResource.read
else
raise MCP::Error.new(
code: MCP::JSONRPC::ErrorCodes::INVALID_PARAMS,
message: "Unknown resource URI: #{uri}"
)
end
}
Step 5: Rebuild assets and test
# Rebuild JavaScript
npm run build
# Increment VERSION in MyWidgetResource to v2, v3, etc. when making changes
# This forces ChatGPT to fetch the updated widget
Step 6: Test in ChatGPT
Add your MCP server to ChatGPT, then reference your new resource in a conversation. ChatGPT will load your widget in an iframe.
Testing
# Run all tests
bin/rails test
# Run specific test file
bin/rails test test/controllers/mcp_controller_test.rb
# Run with verbose output
bin/rails test -v
Project Structure
app/
├── controllers/
│ └── mcp_controller.rb # Main MCP endpoint handler
├── mcp_tools/ # MCP tool implementations
│ ├── get_live_scores_tool.rb
│ └── get_team_info_tool.rb
├── mcp_resources/ # MCP resource implementations
│ ├── live_scores_widget_resource.rb
│ └── team_info_resource.rb
├── javascript/
│ ├── application.js # Component registry & mounting
│ └── components/ # React components
│ └── LiveScoresWidget.jsx
└── views/
└── mcp_widgets/
└── widget.html.erb # Shared widget template
config/
├── routes.rb # MCP endpoint routes
└── initializers/
└── cors.rb # CORS configuration
test/
└── controllers/
└── mcp_controller_test.rb # MCP endpoint tests
License
MIT