Skip to content

Commit da95f0c

Browse files
antonpk1claude
andcommitted
feat(examples): Add video-resource-server example
Demonstrates serving binary content (video) via MCP resources using the base64 blob pattern: 1. Server fetches video from CDN 2. Returns as base64 blob via `videos://{id}` resource template 3. Widget fetches resource and plays in `<video>` element Includes multiple video sizes for testing (1MB to 150MB). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3e95d64 commit da95f0c

10 files changed

Lines changed: 554 additions & 0 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Video Resource Server
2+
3+
Demonstrates serving binary content (video) via MCP resources using the base64 blob pattern.
4+
5+
## Quick Start
6+
7+
```bash
8+
npm install
9+
npm run dev
10+
```
11+
12+
## Tools
13+
14+
- **play_video** - Plays a video loaded via MCP resource
15+
- `videoId`: Choose from various sizes (`bunny-1mb`, `bunny-5mb`, `bunny-10mb`, etc.)
16+
17+
## How It Works
18+
19+
1. The `play_video` tool returns a `videoUri` pointing to an MCP resource
20+
2. The widget fetches the resource via `resources/read`
21+
3. The server fetches the video from CDN and returns it as a base64 blob
22+
4. The widget decodes the blob and plays it in a `<video>` element
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<meta name="color-scheme" content="light dark">
7+
<title>Video Resource Player</title>
8+
</head>
9+
<body>
10+
<main class="main">
11+
<div id="loading" class="loading">
12+
<div class="spinner"></div>
13+
<p id="loading-text">Waiting for video...</p>
14+
</div>
15+
16+
<div id="error" class="error" style="display: none;">
17+
<p class="error-title">Error loading video</p>
18+
<p id="error-message"></p>
19+
</div>
20+
21+
<div id="player" class="player" style="display: none;">
22+
<video id="video" controls></video>
23+
<p id="video-info" class="video-info"></p>
24+
</div>
25+
</main>
26+
<script type="module" src="/src/mcp-app.ts"></script>
27+
</body>
28+
</html>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "video-resource-server",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "cross-env INPUT=mcp-app.html vite build",
8+
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
9+
"serve": "bun server.ts",
10+
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
11+
"dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
12+
},
13+
"dependencies": {
14+
"@modelcontextprotocol/ext-apps": "../..",
15+
"@modelcontextprotocol/sdk": "^1.24.0",
16+
"zod": "^4.1.13"
17+
},
18+
"devDependencies": {
19+
"@types/cors": "^2.8.19",
20+
"@types/express": "^5.0.0",
21+
"@types/node": "^22.0.0",
22+
"concurrently": "^9.2.1",
23+
"cors": "^2.8.5",
24+
"cross-env": "^7.0.3",
25+
"express": "^5.1.0",
26+
"typescript": "^5.9.3",
27+
"vite": "^6.0.0",
28+
"vite-plugin-singlefile": "^2.3.0"
29+
}
30+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/**
2+
* Video Resource Server
3+
*
4+
* Demonstrates serving binary content (video) via MCP resources.
5+
* The server fetches videos from CDN and serves them as base64 blobs.
6+
*/
7+
import {
8+
McpServer,
9+
ResourceTemplate,
10+
} from "@modelcontextprotocol/sdk/server/mcp.js";
11+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12+
import type {
13+
CallToolResult,
14+
ReadResourceResult,
15+
} from "@modelcontextprotocol/sdk/types.js";
16+
import { z } from "zod";
17+
import fs from "node:fs/promises";
18+
import path from "node:path";
19+
import {
20+
registerAppTool,
21+
registerAppResource,
22+
RESOURCE_MIME_TYPE,
23+
RESOURCE_URI_META_KEY,
24+
} from "@modelcontextprotocol/ext-apps/server";
25+
import { startServer } from "../shared/server-utils.js";
26+
27+
const DIST_DIR = path.join(import.meta.dirname, "dist");
28+
const RESOURCE_URI = "ui://video-player/mcp-app.html";
29+
30+
/**
31+
* Video library with different sizes for testing.
32+
*/
33+
const VIDEO_LIBRARY: Record<string, { url: string; description: string }> = {
34+
"bunny-1mb": {
35+
url: "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4",
36+
description: "1MB",
37+
},
38+
"bunny-5mb": {
39+
url: "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_5MB.mp4",
40+
description: "5MB",
41+
},
42+
"bunny-10mb": {
43+
url: "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_10MB.mp4",
44+
description: "10MB",
45+
},
46+
"bunny-20mb": {
47+
url: "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_20MB.mp4",
48+
description: "20MB",
49+
},
50+
"bunny-30mb": {
51+
url: "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/1080/Big_Buck_Bunny_1080_10s_30MB.mp4",
52+
description: "30MB",
53+
},
54+
"bunny-50mb": {
55+
url: "https://sample-videos.com/video321/mp4/720/big_buck_bunny_720p_50mb.mp4",
56+
description: "50MB",
57+
},
58+
"bunny-150mb": {
59+
url: "https://cdn.jsdelivr.net/npm/big-buck-bunny-1080p@0.0.6/video.mp4",
60+
description: "~150MB (full 1080p)",
61+
},
62+
};
63+
64+
function createServer(): McpServer {
65+
const server = new McpServer({
66+
name: "Video Resource Server",
67+
version: "1.0.0",
68+
});
69+
70+
// Register video resource template
71+
// This fetches video from CDN and returns as base64 blob
72+
server.resource(
73+
"video",
74+
new ResourceTemplate("videos://{id}", { list: undefined }),
75+
{
76+
description: "Video served via MCP resource (base64 blob)",
77+
mimeType: "video/mp4",
78+
},
79+
async (uri, { id }): Promise<ReadResourceResult> => {
80+
const idStr = Array.isArray(id) ? id[0] : id;
81+
const video = VIDEO_LIBRARY[idStr];
82+
83+
if (!video) {
84+
throw new Error(
85+
`Video not found: ${idStr}. Available: ${Object.keys(VIDEO_LIBRARY).join(", ")}`,
86+
);
87+
}
88+
89+
console.error(`[video-resource] Fetching: ${video.url}`);
90+
91+
const response = await fetch(video.url);
92+
if (!response.ok) {
93+
throw new Error(
94+
`Failed to fetch video: ${response.status} ${response.statusText}`,
95+
);
96+
}
97+
98+
const buffer = await response.arrayBuffer();
99+
const base64 = Buffer.from(buffer).toString("base64");
100+
101+
console.error(
102+
`[video-resource] Size: ${buffer.byteLength} bytes -> ${base64.length} base64 chars`,
103+
);
104+
105+
return {
106+
contents: [
107+
{
108+
uri: uri.href,
109+
mimeType: "video/mp4",
110+
blob: base64,
111+
},
112+
],
113+
};
114+
},
115+
);
116+
117+
// Register the video player tool
118+
registerAppTool(
119+
server,
120+
"play_video",
121+
{
122+
title: "Play Video via Resource",
123+
description: `Play a video loaded via MCP resource.
124+
Available videos:
125+
${Object.entries(VIDEO_LIBRARY)
126+
.map(([id, v]) => `- ${id}: ${v.description}`)
127+
.join("\n")}`,
128+
inputSchema: {
129+
videoId: z
130+
.enum(Object.keys(VIDEO_LIBRARY) as [string, ...string[]])
131+
.describe(
132+
`Video ID to play. Available: ${Object.keys(VIDEO_LIBRARY).join(", ")}`,
133+
),
134+
},
135+
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
136+
},
137+
async ({ videoId }): Promise<CallToolResult> => {
138+
const video = VIDEO_LIBRARY[videoId];
139+
return {
140+
content: [
141+
{
142+
type: "text",
143+
text: JSON.stringify({
144+
videoUri: `videos://${videoId}`,
145+
description: video.description,
146+
}),
147+
},
148+
],
149+
};
150+
},
151+
);
152+
153+
// Register the MCP App resource (the UI)
154+
registerAppResource(
155+
server,
156+
RESOURCE_URI,
157+
RESOURCE_URI,
158+
{ mimeType: RESOURCE_MIME_TYPE },
159+
async (): Promise<ReadResourceResult> => {
160+
const html = await fs.readFile(
161+
path.join(DIST_DIR, "mcp-app.html"),
162+
"utf-8",
163+
);
164+
return {
165+
contents: [
166+
{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
167+
],
168+
};
169+
},
170+
);
171+
172+
return server;
173+
}
174+
175+
async function main() {
176+
if (process.argv.includes("--stdio")) {
177+
await createServer().connect(new StdioServerTransport());
178+
} else {
179+
const port = parseInt(process.env.PORT ?? "3105", 10);
180+
await startServer(createServer, { port, name: "Video Resource Server" });
181+
}
182+
}
183+
184+
main().catch((e) => {
185+
console.error(e);
186+
process.exit(1);
187+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
* {
2+
box-sizing: border-box;
3+
}
4+
5+
html, body {
6+
font-family: system-ui, -apple-system, sans-serif;
7+
font-size: 1rem;
8+
margin: 0;
9+
padding: 0;
10+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
.main {
2+
width: 100%;
3+
max-width: 640px;
4+
}
5+
6+
.loading {
7+
display: flex;
8+
flex-direction: column;
9+
align-items: center;
10+
justify-content: center;
11+
padding: 2rem;
12+
gap: 1rem;
13+
color: #6b7280;
14+
}
15+
16+
.spinner {
17+
width: 48px;
18+
height: 48px;
19+
border: 4px solid #e5e7eb;
20+
border-top-color: #3b82f6;
21+
border-radius: 50%;
22+
animation: spin 1s linear infinite;
23+
}
24+
25+
@keyframes spin {
26+
to { transform: rotate(360deg); }
27+
}
28+
29+
.error {
30+
padding: 1rem;
31+
color: #dc2626;
32+
}
33+
34+
.error-title {
35+
font-weight: 600;
36+
margin: 0 0 0.5rem 0;
37+
}
38+
39+
.error p {
40+
margin: 0;
41+
}
42+
43+
.player video {
44+
width: 100%;
45+
max-height: 480px;
46+
border-radius: 8px;
47+
background-color: #000;
48+
}
49+
50+
.video-info {
51+
margin-top: 0.5rem;
52+
font-size: 0.875rem;
53+
color: #6b7280;
54+
}

0 commit comments

Comments
 (0)