Skip to content

Media item widget data error#37

Open
kentcdodds wants to merge 2 commits intomainfrom
cursor/media-item-widget-data-error-b167
Open

Media item widget data error#37
kentcdodds wants to merge 2 commits intomainfrom
cursor/media-item-widget-data-error-b167

Conversation

@kentcdodds
Copy link
Owner

@kentcdodds kentcdodds commented Jan 9, 2026

Fixes a schema mismatch between the MediaRSS tool's output and the media player widget's expected input. An adapter layer transforms the tool's { metadata, access } response into the widget's MediaData format, deriving artworkUrl and streamUrl. Includes a new API fallback for environments (like ChatGPT) that don't fully populate toolOutput or initial-render-data, ensuring the widget always receives valid data with robust runtime validation.

Test Plan

  1. Verify with full toolOutput: Simulate a renderData payload where toolOutput contains { metadata, access } (as per ToolOutputSchema). The widget should render correctly.
  2. Verify with minimal toolInput (API fallback): Simulate a renderData payload where toolInput contains only mediaRoot and relativePath, and toolOutput is null. The widget should make a POST /mcp/widget/media-data call and render correctly.
  3. Verify error handling: Provide invalid or incomplete data to ensure runtime validation catches schema mismatches and displays an error.

Checklist

  • Tests updated
  • Docs updated

Screenshots


Open in Cursor Open in Web


Note

Resolves schema mismatch and ensures the media player renders reliably across environments.

  • Adds adapter layer in media-player.tsx with ToolOutputSchema, URL derivation helpers, and extractMediaData to handle multiple input paths (toolOutput, toolInput, or API fallback)
  • Introduces POST /mcp/widget/media-data endpoint (app/routes/mcp/widget-media-data.ts) that validates access (token lookup, feed permissions), reads file metadata, and returns MediaWidgetData
  • Wires new route in app/config/routes.ts and app/router.tsx; widget calls the endpoint when only minimal path params are provided
  • Improves runtime validation and error handling in the widget (detailed errors, robust parsing)

Written by Cursor Bugbot for commit 351a5de. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added new media metadata API endpoint for widget data retrieval.
  • Improvements

    • Enhanced media player data handling with multiple prioritized data sources for better reliability.
    • Expanded error messaging to provide more context about available data during playback issues.

✏️ Tip: You can customize this high-level summary in your review settings.

cursoragent and others added 2 commits January 9, 2026 18:49
Co-authored-by: me <me@kentcdodds.com>
Co-authored-by: me <me@kentcdodds.com>
@cursor
Copy link

cursor bot commented Jan 9, 2026

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

@coderabbitai
Copy link

coderabbitai bot commented Jan 9, 2026

📝 Walkthrough

Walkthrough

This PR introduces a media widget data endpoint and client-side adapter layer for the media player. The backend route validates requests, auto-resolves access tokens by scanning feeds, retrieves file metadata, and serves media data with artwork and stream URLs. The client refactors MediaPlayerApp to extract MediaData through prioritized sources (toolOutput, direct data, or API fallback).

Changes

Cohort / File(s) Summary
Client-Side Media Data Adapter
app/client/widgets/media-player.tsx
Introduced schema definitions (ToolOutputSchema, MinimalInputSchema, RenderDataSchema) and adapter layer to normalize MediaData from multiple sources. Added extractMediaData with prioritized fallback chain: toolOutput → full MediaData → toolInput with metadata → minimal path params via API. Implemented helper functions encodeRelativePath, deriveArtworkUrl, deriveStreamUrl, and adaptToolOutputToMediaData for URL construction and data mapping. Refactored MediaPlayerApp to call extractMediaData asynchronously and enhanced error messaging.
Route Configuration
app/config/routes.ts, app/router.tsx
Added new route entry mcpWidgetMediaData with path /mcp/widget/media-data and corresponding router mapping to mcpWidgetMediaDataHandlers.
Server Media Data Handler
app/routes/mcp/widget-media-data.ts
Implemented new POST endpoint for /mcp/widget/media-data with request validation (mediaRoot, relativePath, token). Includes token auto-resolution by scanning directory and curated feeds if token omitted. Validates token against feeds, resolves file paths, retrieves metadata, and constructs token-based URLs for artwork and streaming. Returns MediaWidgetData payload with metadata fields and sets 5-minute private cache header. Uses dynamic imports to minimize circular dependencies.

Sequence Diagram(s)

sequenceDiagram
    participant Client as MediaPlayerApp
    participant API as /mcp/widget/media-data
    participant FeedDB as Feeds & Directory
    participant FileSystem as File Metadata

    Client->>Client: extractMediaData()
    alt MediaData available in renderData
        Client->>Client: Return existing MediaData
    else
        Client->>API: POST /mcp/widget/media-data<br/>(mediaRoot, relativePath, token?)
        alt Token provided
            API->>API: Validate token
        else
            API->>FeedDB: Scan feeds/directory<br/>for valid token
            FeedDB-->>API: Return accessible token
            API->>API: Validate resolved token
        end
        API->>FileSystem: Get file metadata
        FileSystem-->>API: metadata (title, author, etc.)
        API->>API: Construct artworkUrl<br/>& streamUrl
        API-->>Client: MediaWidgetData
        Client->>Client: Return parsed MediaData
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PR #35: Implements identical token auto-selection logic for media widgets by scanning directory and curated feeds when no token is provided.
  • PR #36: Modifies the same media-player.tsx file and MCP-UI render-data flow for client-side renderData handling and widget data integration.
  • PR #31: Evolves media-widget data handling and token-based artwork/stream URL construction with encoded relative paths.

Poem

🐰 A widget now speaks to the feeds and the files,
Finding its tokens through directory aisles,
With URLs crafted from paths most encoded,
The player's data more richly loaded! 🎵

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Media item widget data error' is vague and does not clearly convey the main change: introducing an adapter layer to fix a schema mismatch and add API fallback for media player widget data. Revise the title to be more specific and descriptive, such as 'Add adapter layer for media player widget data' or 'Fix media player widget schema with toolOutput adapter and API fallback'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@kentcdodds kentcdodds marked this pull request as ready for review January 9, 2026 19:00
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In @app/routes/mcp/widget-media-data.ts:
- Around line 85-87: The method-check branch currently returns a plain text
response (new Response('Method not allowed', { status: 405 })) which is
inconsistent with other error responses; replace it with a JSON response (e.g.,
Response.json({ error: 'Method not allowed' }, { status: 405 })) so the
handler's method-check uses the same JSON error format as other branches and
clients can parse errors consistently; update the code around the
context.request.method check in widget-media-data.ts accordingly.
- Around line 81-190: The endpoint lacks CORS headers so cross-origin widget
requests fail; update the action handler to respond to OPTIONS preflight (when
context.request.method === 'OPTIONS') with a 204 and CORS headers, and include
the same CORS headers on every other Response/Response.json return (including
error responses and the final mediaData response). Add headers such as
Access-Control-Allow-Origin (e.g. '*', or the allowed origin),
Access-Control-Allow-Methods ('POST, OPTIONS'), and Access-Control-Allow-Headers
('Content-Type, Authorization') to all Response/Response.json calls and to the
Response returned for non-POST methods so that getFeedByToken, parse/validation
error responses, and the successful mediaData response all include consistent
CORS headers.
🧹 Nitpick comments (4)
app/client/widgets/media-player.tsx (3)

83-88: Duplicated encodeRelativePath function.

This function duplicates the implementation in app/helpers/feed-access.ts. While the duplication may be intentional for client-side bundle isolation (avoiding server imports), consider extracting shared utilities to a common location that can be imported by both client and server code if the bundler supports it.


182-203: Consider adding a timeout for the fetch request.

The fetch call has no timeout or abort signal. If the server is slow or unresponsive, the widget will remain in a loading state indefinitely. Per the coding guidelines, use this.signal for cancellable async operations to handle component unmounting.

♻️ Suggested improvement

Pass an AbortSignal to the fetch call. The signal could come from the component's this.signal if this function is called from within a Remix component context, or use AbortSignal.timeout():

 async function fetchMediaDataFromApi(
 	mediaRoot: string,
 	relativePath: string,
 	token?: string,
+	signal?: AbortSignal,
 ): Promise<MediaData> {
 	const response = await fetch('/mcp/widget/media-data', {
 		method: 'POST',
 		headers: { 'Content-Type': 'application/json' },
 		body: JSON.stringify({ mediaRoot, relativePath, token }),
+		signal: signal ?? AbortSignal.timeout(30000),
 	})

705-733: Consider using this.signal for async operation cancellation.

Per the coding guidelines, use this.signal for cancellable async operations in Remix components to handle component unmounting. If the component unmounts while extractMediaData is in progress (especially during the API fetch fallback), this.update() may be called on a destroyed handle.

♻️ Suggested approach
 function MediaPlayerApp(this: Handle) {
 	let state: 'loading' | 'ready' | 'error' = 'loading'
 	let media: MediaData | null = null
 	let errorMessage = ''
+	const signal = this.signal

 	// Request render data from parent frame
 	void waitForRenderData(RenderDataSchema)
 		.then(async (renderData) => {
+			if (signal.aborted) return
 			console.log('[MediaPlayer] Received render data:', renderData)

 			// Use the adapter to extract and transform media data
 			media = await extractMediaData(renderData)
+			if (signal.aborted) return
 			state = 'ready'
 			this.update()
 		})
 		.catch((err) => {
+			if (signal.aborted) return
 			console.error('[MediaPlayer] Error receiving render data:', err)
app/routes/mcp/widget-media-data.ts (1)

38-75: Consider performance for large feed counts.

The function iterates through all directory and curated feeds sequentially. For deployments with many feeds, this could be slow. The dynamic imports also re-resolve on each call.

For now this is likely acceptable given the fallback nature of this code path, but worth noting for future optimization if needed.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 649a30d and 351a5de.

📒 Files selected for processing (4)
  • app/client/widgets/media-player.tsx
  • app/config/routes.ts
  • app/router.tsx
  • app/routes/mcp/widget-media-data.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: Do not use React, Preact, or any other UI framework. Use @remix-run/component for UI components instead
Bun automatically loads .env files, so do not use dotenv package
Use Bun.env instead of process.env to access environment variables for consistency
Use bun:sqlite for SQLite instead of better-sqlite3
Use Bun.redis for Redis instead of ioredis
Use Bun.sql for Postgres instead of pg or postgres.js
Use built-in WebSocket instead of ws package
Prefer Bun.file over node:fs readFile/writeFile for file operations
Use Bun.$ template literals instead of execa for shell commands

Files:

  • app/config/routes.ts
  • app/routes/mcp/widget-media-data.ts
  • app/client/widgets/media-player.tsx
  • app/router.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: For Remix stateless components, return JSX directly without using state management
For Remix stateful components, use this: Handle and return a function that returns JSX, with state living in the closure
Use renderProps inside the render function to get the latest prop values in Remix components with both props and state, and use setupProps for initial setup
Use on={{ eventName: handler }} syntax instead of onClick or other camelCase event handlers in Remix components
Use the css prop for inline styles with pseudo-selector support in Remix components
Use this.on() to subscribe to custom events or other event targets in Remix components
Use this.signal for cancellable async operations in Remix components to handle component unmounting
Use the connect prop instead of React-style refs to detect when an element is added to the DOM
Use this.context.set() in Remix components to provide context and this.context.get(ProviderComponent) to retrieve context from ancestor components

Files:

  • app/config/routes.ts
  • app/routes/mcp/widget-media-data.ts
  • app/client/widgets/media-player.tsx
  • app/router.tsx
🧠 Learnings (1)
📚 Learning: 2026-01-07T19:23:24.667Z
Learnt from: CR
Repo: kentcdodds/mediarss PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-07T19:23:24.667Z
Learning: Applies to **/*.{ts,tsx} : For Remix stateless components, return JSX directly without using state management

Applied to files:

  • app/client/widgets/media-player.tsx
🧬 Code graph analysis (1)
app/client/widgets/media-player.tsx (1)
app/helpers/feed-access.ts (1)
  • encodeRelativePath (60-65)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (10)
app/config/routes.ts (1)

16-16: LGTM!

The new route entry is properly placed within the MCP routes section and follows the existing naming conventions.

app/router.tsx (1)

35-35: LGTM!

The import and route mapping are correctly placed. Importantly, mcpWidgetMediaData is registered before mcpWidget to ensure the static path matches before the parameterized wildcard route.

Also applies to: 150-151

app/client/widgets/media-player.tsx (6)

23-35: LGTM!

The MediaDataSchema is well-documented as the widget's contract. The comment warning against changing this schema helps maintain backward compatibility.


68-73: Appropriate use of z.unknown().nullable() for external data.

Using z.unknown().nullable() with .passthrough() is the right approach for data coming from external sources (ChatGPT's MCP-UI protocol), allowing flexible validation while preserving additional properties.


122-144: LGTM - Robust adapter with double validation.

The adapter correctly parses the tool output, maps fields, and re-validates through MediaDataSchema.parse. This double validation ensures any schema drift between ToolOutputSchema and MediaDataSchema surfaces immediately at runtime.


149-158: LGTM!

Type guard functions using safeParse are an idiomatic Zod pattern for runtime type checking without throwing.


217-278: LGTM - Well-structured priority-based extraction.

The multi-level priority system handles various MCP-UI implementation scenarios gracefully. The detailed error message with availableData context will help debug integration issues when neither toolInput nor toolOutput matches expected schemas.


367-396: LGTM - Stateless component per coding guidelines.

MetadataItem correctly returns JSX directly without state management, following the Remix stateless component pattern.

app/routes/mcp/widget-media-data.ts (2)

27-31: LGTM!

Request schema is appropriately minimal, allowing the server to auto-resolve tokens when not provided.


171-183: The types are properly synchronized between server and client. Verification confirms MediaWidgetData and MediaDataSchema have matching fields and types with consistent nullability handling.

Comment on lines +81 to +190
export default {
middleware: [],
async action(context) {
// Only allow POST
if (context.request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 })
}

// Parse request body
let body: unknown
try {
body = await context.request.json()
} catch {
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
}

// Validate request
const parseResult = RequestSchema.safeParse(body)
if (!parseResult.success) {
return Response.json(
{ error: 'Invalid request', details: parseResult.error.issues },
{ status: 400 },
)
}

const { mediaRoot, relativePath, token: providedToken } = parseResult.data

// If no token provided, find one automatically
let token = providedToken
if (!token) {
token = (await findTokenForMedia(mediaRoot, relativePath)) ?? undefined
if (!token) {
return Response.json(
{
error: 'No access token available',
message:
'No feed has access to this media file, or no active tokens exist.',
},
{ status: 403 },
)
}
}

// Validate token and get feed
const result = getFeedByToken(token)
if (!result) {
return Response.json(
{ error: 'Invalid or expired token' },
{ status: 401 },
)
}

const { feed, type } = result

// Parse and validate the path
const parsed = parseMediaPathStrict(`${mediaRoot}/${relativePath}`)
if (!parsed) {
return Response.json({ error: 'Invalid path format' }, { status: 400 })
}

// Get absolute path for the file
const filePath = toAbsolutePath(parsed.rootName, parsed.relativePath)
if (!filePath) {
return Response.json({ error: 'Unknown media root' }, { status: 404 })
}

// Validate file is allowed for this feed
if (!isFileAllowed(feed, type, parsed.rootName, parsed.relativePath)) {
return Response.json(
{ error: 'File not found or not accessible' },
{ status: 404 },
)
}

// Get file metadata
const metadata = await getFileMetadata(filePath)
if (!metadata) {
return Response.json(
{ error: 'Could not read media file metadata' },
{ status: 404 },
)
}

// Build token-based URLs
const encodedPath = encodeRelativePath(
`${parsed.rootName}/${parsed.relativePath}`,
)
const baseUrl = getOrigin(context.request, context.url)

// Build the response data
const mediaData: MediaWidgetData = {
title: metadata.title,
author: metadata.author,
duration: metadata.duration,
sizeBytes: metadata.sizeBytes,
mimeType: metadata.mimeType,
publicationDate: metadata.publicationDate?.toISOString() ?? null,
description: metadata.description,
narrators: metadata.narrators,
genres: metadata.genres,
artworkUrl: `${baseUrl}/art/${token}/${encodedPath}`,
streamUrl: `${baseUrl}/media/${token}/${encodedPath}`,
}

return Response.json(mediaData, {
headers: {
'Cache-Control': 'private, max-age=300', // 5 minute cache
},
})
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing CORS headers for cross-origin widget requests.

The media player widget is embedded in external apps like ChatGPT, which will make cross-origin requests to this endpoint. Without CORS headers, these requests will fail in browsers.

🔒️ Suggested fix - add CORS headers
 export default {
-	middleware: [],
+	middleware: [
+		// Handle CORS preflight
+		async (context, next) => {
+			if (context.request.method === 'OPTIONS') {
+				return new Response(null, {
+					status: 204,
+					headers: {
+						'Access-Control-Allow-Origin': '*',
+						'Access-Control-Allow-Methods': 'POST, OPTIONS',
+						'Access-Control-Allow-Headers': 'Content-Type',
+						'Access-Control-Max-Age': '86400',
+					},
+				})
+			}
+			return next()
+		},
+	],
 	async action(context) {

And add CORS headers to the response:

 		return Response.json(mediaData, {
 			headers: {
 				'Cache-Control': 'private, max-age=300',
+				'Access-Control-Allow-Origin': '*',
 			},
 		})
🤖 Prompt for AI Agents
In @app/routes/mcp/widget-media-data.ts around lines 81 - 190, The endpoint
lacks CORS headers so cross-origin widget requests fail; update the action
handler to respond to OPTIONS preflight (when context.request.method ===
'OPTIONS') with a 204 and CORS headers, and include the same CORS headers on
every other Response/Response.json return (including error responses and the
final mediaData response). Add headers such as Access-Control-Allow-Origin (e.g.
'*', or the allowed origin), Access-Control-Allow-Methods ('POST, OPTIONS'), and
Access-Control-Allow-Headers ('Content-Type, Authorization') to all
Response/Response.json calls and to the Response returned for non-POST methods
so that getFeedByToken, parse/validation error responses, and the successful
mediaData response all include consistent CORS headers.

Comment on lines +85 to +87
if (context.request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 })
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent error response format.

This returns plain text while other error responses use Response.json(). For consistency and easier client-side error handling, use JSON format.

🐛 Suggested fix
 		if (context.request.method !== 'POST') {
-			return new Response('Method not allowed', { status: 405 })
+			return Response.json({ error: 'Method not allowed' }, { status: 405 })
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (context.request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 })
}
if (context.request.method !== 'POST') {
return Response.json({ error: 'Method not allowed' }, { status: 405 })
}
🤖 Prompt for AI Agents
In @app/routes/mcp/widget-media-data.ts around lines 85 - 87, The method-check
branch currently returns a plain text response (new Response('Method not
allowed', { status: 405 })) which is inconsistent with other error responses;
replace it with a JSON response (e.g., Response.json({ error: 'Method not
allowed' }, { status: 405 })) so the handler's method-check uses the same JSON
error format as other branches and clients can parse errors consistently; update
the code around the context.request.method check in widget-media-data.ts
accordingly.

{ status: 403 },
)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unauthenticated endpoint exposes access tokens in response URLs

Medium Severity

The new /mcp/widget/media-data endpoint has no authentication middleware and automatically discovers access tokens via findTokenForMedia when none is provided. These discovered tokens are then embedded in the artworkUrl and streamUrl response fields. An unauthenticated caller who knows or guesses valid file paths can extract valid tokens from the response URLs and use them to access other files within the same feed, bypassing the intended token-based access control model.

Additional Locations (1)

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants