Add QR code Python MCP server example (#105)
* Add QR code Python MCP server example A minimal Python MCP server that generates customizable QR codes with an interactive widget UI. Features: - Generate QR codes from any text or URL - Customizable colors, size, border, and error correction - Interactive widget using MCP Apps SDK protocol - Supports both HTTP (for web clients) and stdio (for Claude Desktop) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Potential fix for code scanning alert no. 10: Client-side cross-site scripting Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Add semicolon * Bind to 0.0.0.0 for Docker compatibility * Use SDK from unpkg + add CSP resourceDomains for external scripts --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
parent
3ac7c7935b
commit
546f507e49
5 changed files with 344 additions and 0 deletions
25
examples/qr-server/.gitignore
vendored
Normal file
25
examples/qr-server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Python virtual environment
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Python cache
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
163
examples/qr-server/README.md
Normal file
163
examples/qr-server/README.md
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
# QR Code MCP Server
|
||||||
|
|
||||||
|
A minimal Python MCP server that generates customizable QR codes with an interactive widget UI.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Generate QR codes from any text or URL
|
||||||
|
- Customizable colors, size, and error correction
|
||||||
|
- Interactive widget that displays in MCP-UI enabled clients
|
||||||
|
- Supports both HTTP (for web clients) and stdio (for Claude Desktop)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create virtual environment
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run server (HTTP mode)
|
||||||
|
python server.py
|
||||||
|
# → QR Server listening on http://localhost:3108/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### HTTP Mode (for basic-host / web clients)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Connect from basic-host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SERVERS='["http://localhost:3108/mcp"]' bun serve.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stdio Mode (for Claude Desktop)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py --stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"qr": {
|
||||||
|
"command": "/path/to/qr-server/.venv/bin/python",
|
||||||
|
"args": ["/path/to/qr-server/server.py", "--stdio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (accessing host server from container)
|
||||||
|
|
||||||
|
```
|
||||||
|
http://host.docker.internal:3108/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool: `generate_qr`
|
||||||
|
|
||||||
|
Generate a QR code with optional customization.
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Default | Description |
|
||||||
|
| ------------------ | ------ | ---------- | ------------------------------- |
|
||||||
|
| `text` | string | (required) | The text or URL to encode |
|
||||||
|
| `box_size` | int | 10 | Size of each box in pixels |
|
||||||
|
| `border` | int | 4 | Border size in boxes |
|
||||||
|
| `error_correction` | string | "M" | Error correction level: L/M/Q/H |
|
||||||
|
| `fill_color` | string | "black" | Foreground color (hex or name) |
|
||||||
|
| `back_color` | string | "white" | Background color (hex or name) |
|
||||||
|
|
||||||
|
### Error Correction Levels
|
||||||
|
|
||||||
|
| Level | Recovery | Use Case |
|
||||||
|
| ----- | -------- | ------------------------- |
|
||||||
|
| L | 7% | Clean environments |
|
||||||
|
| M | 15% | General use (default) |
|
||||||
|
| Q | 25% | Industrial/outdoor |
|
||||||
|
| H | 30% | Adding logos/damage-prone |
|
||||||
|
|
||||||
|
### Example Inputs
|
||||||
|
|
||||||
|
**Basic:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "text": "https://example.com" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Styled:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "https://claude.ai",
|
||||||
|
"fill_color": "#CC785C",
|
||||||
|
"back_color": "#FFF8F5",
|
||||||
|
"box_size": 12,
|
||||||
|
"border": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dark Mode:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Hello World",
|
||||||
|
"fill_color": "#E0E0E0",
|
||||||
|
"back_color": "#1a1a1a",
|
||||||
|
"box_size": 15,
|
||||||
|
"border": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**WiFi QR Code:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "WIFI:T:WPA;S:MyNetwork;P:MyPassword;;",
|
||||||
|
"error_correction": "H",
|
||||||
|
"box_size": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
qr-server/
|
||||||
|
├── server.py # MCP server (FastMCP + uvicorn)
|
||||||
|
├── widget.html # Interactive UI widget
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protocol
|
||||||
|
|
||||||
|
The widget uses MCP Apps SDK protocol:
|
||||||
|
|
||||||
|
1. Widget sends `ui/initialize` request
|
||||||
|
2. Host responds with capabilities
|
||||||
|
3. Widget sends `ui/notifications/initialized`
|
||||||
|
4. Host sends `ui/notifications/tool-result` with QR image
|
||||||
|
5. Widget renders image and sends `ui/notifications/size-changed`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `mcp[cli]` - MCP Python SDK with FastMCP
|
||||||
|
- `qrcode[pil]` - QR code generation with Pillow
|
||||||
|
- `uvicorn` - ASGI server (included with mcp)
|
||||||
|
- `starlette` - CORS middleware (included with mcp)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
2
examples/qr-server/requirements.txt
Normal file
2
examples/qr-server/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
mcp[cli]>=1.0.0
|
||||||
|
qrcode[pil]>=7.4
|
||||||
102
examples/qr-server/server.py
Normal file
102
examples/qr-server/server.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""
|
||||||
|
QR Code MCP Server - Generates QR codes from text
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
import uvicorn
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
from mcp.types import ImageContent
|
||||||
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
WIDGET_URI = "ui://qr-server/widget.html"
|
||||||
|
HOST = os.environ.get("HOST", "0.0.0.0") # 0.0.0.0 for Docker compatibility
|
||||||
|
PORT = int(os.environ.get("PORT", "3108"))
|
||||||
|
|
||||||
|
mcp = FastMCP("QR Server", port=PORT, stateless_http=True)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(meta={"ui/resourceUri": WIDGET_URI})
|
||||||
|
def generate_qr(
|
||||||
|
text: str,
|
||||||
|
box_size: int = 10,
|
||||||
|
border: int = 4,
|
||||||
|
error_correction: str = "M",
|
||||||
|
fill_color: str = "black",
|
||||||
|
back_color: str = "white",
|
||||||
|
) -> list[ImageContent]:
|
||||||
|
"""Generate a QR code from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text/URL to encode
|
||||||
|
box_size: Size of each box in pixels (default: 10)
|
||||||
|
border: Border size in boxes (default: 4)
|
||||||
|
error_correction: Error correction level - L(7%), M(15%), Q(25%), H(30%)
|
||||||
|
fill_color: Foreground color (hex like #FF0000 or name like red)
|
||||||
|
back_color: Background color (hex like #FFFFFF or name like white)
|
||||||
|
"""
|
||||||
|
error_levels = {
|
||||||
|
"L": qrcode.constants.ERROR_CORRECT_L,
|
||||||
|
"M": qrcode.constants.ERROR_CORRECT_M,
|
||||||
|
"Q": qrcode.constants.ERROR_CORRECT_Q,
|
||||||
|
"H": qrcode.constants.ERROR_CORRECT_H,
|
||||||
|
}
|
||||||
|
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=1,
|
||||||
|
error_correction=error_levels.get(error_correction.upper(), qrcode.constants.ERROR_CORRECT_M),
|
||||||
|
box_size=box_size,
|
||||||
|
border=border,
|
||||||
|
)
|
||||||
|
qr.add_data(text)
|
||||||
|
qr.make(fit=True)
|
||||||
|
|
||||||
|
img = qr.make_image(fill_color=fill_color, back_color=back_color)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format="PNG")
|
||||||
|
b64 = base64.b64encode(buffer.getvalue()).decode()
|
||||||
|
return [ImageContent(type="image", data=b64, mimeType="image/png")]
|
||||||
|
|
||||||
|
|
||||||
|
# IMPORTANT: resourceDomains needed for CSP to allow loading SDK from unpkg.com
|
||||||
|
# Without this, hosts enforcing CSP will block the external script import
|
||||||
|
@mcp.resource(WIDGET_URI, mime_type="text/html")
|
||||||
|
def widget() -> dict:
|
||||||
|
html = Path(__file__).parent.joinpath("widget.html").read_text()
|
||||||
|
return {
|
||||||
|
"text": html,
|
||||||
|
"_meta": {
|
||||||
|
"ui": {
|
||||||
|
"csp": {
|
||||||
|
"resourceDomains": ["https://unpkg.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HACK: Bypass SDK's restrictive mime_type validation
|
||||||
|
# The SDK pattern doesn't allow ";profile=mcp-app" but MCP spec requires it for widgets
|
||||||
|
# https://github.com/modelcontextprotocol/python-sdk/pull/1755
|
||||||
|
for resource in mcp._resource_manager._resources.values():
|
||||||
|
if str(resource.uri) == WIDGET_URI:
|
||||||
|
object.__setattr__(resource, 'mime_type', 'text/html;profile=mcp-app')
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--stdio" in sys.argv:
|
||||||
|
# Claude Desktop mode
|
||||||
|
mcp.run(transport="stdio")
|
||||||
|
else:
|
||||||
|
# HTTP mode for basic-host (default) - with CORS
|
||||||
|
app = mcp.streamable_http_app()
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
print(f"QR Server listening on http://{HOST}:{PORT}/mcp")
|
||||||
|
uvicorn.run(app, host=HOST, port=PORT)
|
||||||
52
examples/qr-server/widget.html
Normal file
52
examples/qr-server/widget.html
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 340px;
|
||||||
|
width: 340px;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="qr"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { App, PostMessageTransport } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.0.4";
|
||||||
|
|
||||||
|
const app = new App({ name: "QR Widget", version: "1.0.0" });
|
||||||
|
|
||||||
|
app.ontoolresult = ({ content }) => {
|
||||||
|
const img = content?.find(c => c.type === 'image');
|
||||||
|
if (img) {
|
||||||
|
const qrDiv = document.getElementById('qr');
|
||||||
|
qrDiv.innerHTML = '';
|
||||||
|
|
||||||
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif'];
|
||||||
|
const mimeType = allowedTypes.includes(img.mimeType) ? img.mimeType : 'image/png';
|
||||||
|
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.src = `data:${mimeType};base64,${img.data}`;
|
||||||
|
image.alt = "QR Code";
|
||||||
|
qrDiv.appendChild(image);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await app.connect(new PostMessageTransport(window.parent));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue