Skip to content

Commit 299a442

Browse files
authored
Merge pull request #175 from modelcontextprotocol/antonp/video-resource-example
feat(examples): Add video-resource-server example
2 parents d925af5 + ea7d2ee commit 299a442

10 files changed

Lines changed: 550 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: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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-150mb": {
55+
url: "https://cdn.jsdelivr.net/npm/big-buck-bunny-1080p@0.0.6/video.mp4",
56+
description: "~150MB (full 1080p)",
57+
},
58+
};
59+
60+
function createServer(): McpServer {
61+
const server = new McpServer({
62+
name: "Video Resource Server",
63+
version: "1.0.0",
64+
});
65+
66+
// Register video resource template
67+
// This fetches video from CDN and returns as base64 blob
68+
server.registerResource(
69+
"video",
70+
new ResourceTemplate("videos://{id}", { list: undefined }),
71+
{
72+
description: "Video served via MCP resource (base64 blob)",
73+
mimeType: "video/mp4",
74+
},
75+
async (uri, { id }): Promise<ReadResourceResult> => {
76+
const idStr = Array.isArray(id) ? id[0] : id;
77+
const video = VIDEO_LIBRARY[idStr];
78+
79+
if (!video) {
80+
throw new Error(
81+
`Video not found: ${idStr}. Available: ${Object.keys(VIDEO_LIBRARY).join(", ")}`,
82+
);
83+
}
84+
85+
console.error(`[video-resource] Fetching: ${video.url}`);
86+
87+
const response = await fetch(video.url);
88+
if (!response.ok) {
89+
throw new Error(
90+
`Failed to fetch video: ${response.status} ${response.statusText}`,
91+
);
92+
}
93+
94+
const buffer = await response.arrayBuffer();
95+
const base64 = Buffer.from(buffer).toString("base64");
96+
97+
console.error(
98+
`[video-resource] Size: ${buffer.byteLength} bytes -> ${base64.length} base64 chars`,
99+
);
100+
101+
return {
102+
contents: [
103+
{
104+
uri: uri.href,
105+
mimeType: "video/mp4",
106+
blob: base64,
107+
},
108+
],
109+
};
110+
},
111+
);
112+
113+
// Register the video player tool
114+
registerAppTool(
115+
server,
116+
"play_video",
117+
{
118+
title: "Play Video via Resource",
119+
description: `Play a video loaded via MCP resource.
120+
Available videos:
121+
${Object.entries(VIDEO_LIBRARY)
122+
.map(([id, v]) => `- ${id}: ${v.description}`)
123+
.join("\n")}`,
124+
inputSchema: {
125+
videoId: z
126+
.enum(Object.keys(VIDEO_LIBRARY) as [string, ...string[]])
127+
.describe(
128+
`Video ID to play. Available: ${Object.keys(VIDEO_LIBRARY).join(", ")}`,
129+
),
130+
},
131+
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
132+
},
133+
async ({ videoId }): Promise<CallToolResult> => {
134+
const video = VIDEO_LIBRARY[videoId];
135+
return {
136+
content: [
137+
{
138+
type: "text",
139+
text: JSON.stringify({
140+
videoUri: `videos://${videoId}`,
141+
description: video.description,
142+
}),
143+
},
144+
],
145+
};
146+
},
147+
);
148+
149+
// Register the MCP App resource (the UI)
150+
registerAppResource(
151+
server,
152+
RESOURCE_URI,
153+
RESOURCE_URI,
154+
{ mimeType: RESOURCE_MIME_TYPE },
155+
async (): Promise<ReadResourceResult> => {
156+
const html = await fs.readFile(
157+
path.join(DIST_DIR, "mcp-app.html"),
158+
"utf-8",
159+
);
160+
return {
161+
contents: [
162+
{ uri: RESOURCE_URI, mimeType: RESOURCE_MIME_TYPE, text: html },
163+
],
164+
};
165+
},
166+
);
167+
168+
return server;
169+
}
170+
171+
async function main() {
172+
if (process.argv.includes("--stdio")) {
173+
await createServer().connect(new StdioServerTransport());
174+
} else {
175+
const port = parseInt(process.env.PORT ?? "3105", 10);
176+
await startServer(createServer, { port, name: "Video Resource Server" });
177+
}
178+
}
179+
180+
main().catch((e) => {
181+
console.error(e);
182+
process.exit(1);
183+
});
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)