Skip to content

Commit da91186

Browse files
committed
Add paint-server example: minimalistic drawing canvas with model context
Simple MCP App example that provides a drawing canvas where users can: - Pick colors from a palette - Draw freehand with pointer/touch - Clear the canvas The drawing is debounced (1s) and sent as a PNG image via updateModelContext so the model can see what was drawn. Export is scaled to max 256px on longest side to fit within the 4000 token model context limit.
1 parent 8c3b1da commit da91186

9 files changed

Lines changed: 537 additions & 0 deletions

File tree

examples/paint-server/main.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Entry point for the paint MCP server.
3+
*/
4+
5+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
7+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9+
import cors from "cors";
10+
import type { Request, Response } from "express";
11+
import { createServer } from "./server.js";
12+
13+
export interface ServerOptions {
14+
port: number;
15+
name?: string;
16+
}
17+
18+
export async function startServer(
19+
createServer: () => McpServer,
20+
options: ServerOptions,
21+
): Promise<void> {
22+
const { port, name = "MCP Server" } = options;
23+
24+
const app = createMcpExpressApp({ host: "0.0.0.0" });
25+
app.use(cors());
26+
27+
app.all("/mcp", async (req: Request, res: Response) => {
28+
const server = createServer();
29+
const transport = new StreamableHTTPServerTransport({
30+
sessionIdGenerator: undefined,
31+
});
32+
33+
res.on("close", () => {
34+
transport.close().catch(() => {});
35+
server.close().catch(() => {});
36+
});
37+
38+
try {
39+
await server.connect(transport);
40+
await transport.handleRequest(req, res, req.body);
41+
} catch (error) {
42+
console.error("MCP error:", error);
43+
if (!res.headersSent) {
44+
res.status(500).json({
45+
jsonrpc: "2.0",
46+
error: { code: -32603, message: "Internal server error" },
47+
id: null,
48+
});
49+
}
50+
}
51+
});
52+
53+
const httpServer = app.listen(port, (err) => {
54+
if (err) {
55+
console.error("Failed to start server:", err);
56+
process.exit(1);
57+
}
58+
console.log(`${name} listening on http://localhost:${port}/mcp`);
59+
});
60+
61+
const shutdown = () => {
62+
console.log("\nShutting down...");
63+
httpServer.close(() => process.exit(0));
64+
};
65+
66+
process.on("SIGINT", shutdown);
67+
process.on("SIGTERM", shutdown);
68+
}
69+
70+
async function main() {
71+
if (process.argv.includes("--stdio")) {
72+
await createServer().connect(new StdioServerTransport());
73+
} else {
74+
const port = parseInt(process.env.PORT ?? "3108", 10);
75+
await startServer(createServer, { port, name: "Paint MCP App Server" });
76+
}
77+
}
78+
79+
main().catch((e) => {
80+
console.error(e);
81+
process.exit(1);
82+
});

examples/paint-server/mcp-app.html

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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>Paint</title>
8+
</head>
9+
<body>
10+
<main id="main">
11+
<div id="toolbar">
12+
<div id="colors"></div>
13+
<button id="clear-btn" title="Clear canvas">Clear</button>
14+
</div>
15+
<canvas id="canvas"></canvas>
16+
</main>
17+
<script type="module" src="/src/mcp-app.ts"></script>
18+
</body>
19+
</html>

examples/paint-server/package.json

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"name": "@modelcontextprotocol/server-paint",
3+
"version": "0.4.1",
4+
"type": "module",
5+
"description": "Minimalistic painting MCP App with model context image updates",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/modelcontextprotocol/ext-apps",
9+
"directory": "examples/paint-server"
10+
},
11+
"license": "MIT",
12+
"main": "dist/server.js",
13+
"files": [
14+
"dist"
15+
],
16+
"scripts": {
17+
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build && tsc -p tsconfig.server.json && bun build server.ts --outdir dist --target node && bun build main.ts --outfile dist/index.js --target node --external \"./server.js\" --banner \"#!/usr/bin/env node\"",
18+
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
19+
"serve": "bun --watch main.ts",
20+
"start": "cross-env NODE_ENV=development npm run build && npm run serve",
21+
"dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve'"
22+
},
23+
"dependencies": {
24+
"@modelcontextprotocol/ext-apps": "^0.4.1",
25+
"@modelcontextprotocol/sdk": "^1.24.0",
26+
"cors": "^2.8.5",
27+
"express": "^5.1.0",
28+
"zod": "^4.1.13"
29+
},
30+
"devDependencies": {
31+
"@types/cors": "^2.8.19",
32+
"@types/express": "^5.0.0",
33+
"@types/node": "^22.0.0",
34+
"concurrently": "^9.2.1",
35+
"cross-env": "^10.1.0",
36+
"typescript": "^5.9.3",
37+
"vite": "^6.0.0",
38+
"vite-plugin-singlefile": "^2.3.0"
39+
},
40+
"types": "dist/server.d.ts",
41+
"exports": {
42+
".": {
43+
"types": "./dist/server.d.ts",
44+
"default": "./dist/server.js"
45+
}
46+
},
47+
"bin": {
48+
"mcp-server-paint": "dist/index.js"
49+
}
50+
}

examples/paint-server/server.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Paint MCP App Server
3+
*
4+
* Provides a simple drawing canvas tool. The widget sends the current
5+
* drawing as an image via updateModelContext so the model can "see" it.
6+
*/
7+
8+
import fs from "node:fs/promises";
9+
import path from "node:path";
10+
import { fileURLToPath } from "node:url";
11+
12+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13+
import {
14+
registerAppTool,
15+
registerAppResource,
16+
RESOURCE_MIME_TYPE,
17+
} from "@modelcontextprotocol/ext-apps/server";
18+
import type {
19+
CallToolResult,
20+
ReadResourceResult,
21+
} from "@modelcontextprotocol/sdk/types.js";
22+
23+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
24+
const DIST_DIR = __dirname; // mcp-app.html is in the same dir as the built server
25+
26+
const resourceUri = "ui://draw/mcp-app.html";
27+
28+
export function createServer(): McpServer {
29+
const server = new McpServer({
30+
name: "paint",
31+
version: "1.0.0",
32+
});
33+
34+
registerAppTool(
35+
server,
36+
"draw",
37+
{
38+
title: "Draw",
39+
description:
40+
"Opens a drawing canvas where the user can paint with different colors. " +
41+
"The current drawing is automatically shared as model context.",
42+
inputSchema: {},
43+
_meta: { ui: { resourceUri } },
44+
},
45+
async (): Promise<CallToolResult> => {
46+
return {
47+
content: [
48+
{
49+
type: "text",
50+
text: "Drawing canvas opened. The user can now draw. Their drawing will be shared with you as model context (image). Ask the user what they drew, or wait for them to tell you.",
51+
},
52+
],
53+
};
54+
},
55+
);
56+
57+
registerAppResource(
58+
server,
59+
resourceUri,
60+
resourceUri,
61+
{ mimeType: RESOURCE_MIME_TYPE },
62+
async (): Promise<ReadResourceResult> => {
63+
const html = await fs.readFile(
64+
path.join(DIST_DIR, "mcp-app.html"),
65+
"utf-8",
66+
);
67+
return {
68+
contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
69+
};
70+
},
71+
);
72+
73+
return server;
74+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
* {
2+
box-sizing: border-box;
3+
margin: 0;
4+
padding: 0;
5+
}
6+
7+
html,
8+
body {
9+
height: 100%;
10+
overflow: hidden;
11+
font-family: system-ui, sans-serif;
12+
background: var(--color-bg-primary, #fff);
13+
}
14+
15+
#main {
16+
display: flex;
17+
flex-direction: column;
18+
height: 100%;
19+
}
20+
21+
#toolbar {
22+
display: flex;
23+
align-items: center;
24+
gap: 8px;
25+
padding: 8px;
26+
border-bottom: 1px solid var(--color-border-primary, #ddd);
27+
background: var(--color-bg-secondary, #f5f5f5);
28+
}
29+
30+
#colors {
31+
display: flex;
32+
gap: 4px;
33+
flex-wrap: wrap;
34+
}
35+
36+
.color-swatch {
37+
width: 24px;
38+
height: 24px;
39+
border-radius: 50%;
40+
border: 2px solid transparent;
41+
cursor: pointer;
42+
transition: border-color 0.1s;
43+
}
44+
45+
.color-swatch:hover {
46+
border-color: var(--color-text-secondary, #666);
47+
}
48+
49+
.color-swatch.active {
50+
border-color: var(--color-text-primary, #000);
51+
box-shadow: 0 0 0 2px var(--color-bg-primary, #fff),
52+
0 0 0 4px var(--color-text-primary, #000);
53+
}
54+
55+
#clear-btn {
56+
margin-left: auto;
57+
padding: 4px 12px;
58+
border: 1px solid var(--color-border-primary, #ccc);
59+
border-radius: 4px;
60+
background: var(--color-bg-primary, #fff);
61+
color: var(--color-text-primary, #333);
62+
cursor: pointer;
63+
font-size: 12px;
64+
}
65+
66+
#clear-btn:hover {
67+
background: var(--color-bg-tertiary, #eee);
68+
}
69+
70+
#canvas {
71+
flex: 1;
72+
width: 100%;
73+
cursor: crosshair;
74+
touch-action: none;
75+
}

0 commit comments

Comments
 (0)