blip-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ben Krämer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ <p align="center">
2
+ <img src="logo.png" alt="Blip" width="200" />
3
+ </p>
4
+
5
+ <p align="center">
6
+ Visual annotation MCP server for Claude Code.<br>
7
+ Draw on your preview. Claude sees what you mean.
8
+ </p>
9
+
10
+ ---
11
+
12
+ ## How it works
13
+
14
+ 1. Tell Claude: **"annotate"** (or **"annotate http://localhost:3000"**)
15
+ 2. A browser opens with drawing tools on your page
16
+ 3. Draw circles, arrows, highlights, text labels
17
+ 4. Click **Send to Claude** -- the annotated screenshot goes back to your chat
18
+ 5. Claude updates the code based on what you drew
19
+
20
+ No more describing UI changes with words. Just draw on them.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ claude mcp add blip -- npx blip-mcp
26
+ ```
27
+
28
+ That's it. Requires [Claude Code](https://claude.com/claude-code) and Node.js 18+.
29
+
30
+ ## Two modes
31
+
32
+ **CLI (Terminal)** -- The `annotate` MCP tool opens your page in a browser with the overlay injected. Draw, hit send, the screenshot goes straight back to Claude.
33
+
34
+ **Desktop (Claude Code app)** -- The overlay works with Claude Code's built-in preview. Click the pencil button to activate drawing, then press Ctrl+P to add the screenshot to chat.
35
+
36
+ ## Development
37
+
38
+ ```bash
39
+ git clone https://github.com/nebenzu/Blip.git
40
+ cd Blip
41
+ npm install
42
+ npm run build
43
+ ```
44
+
45
+ ```bash
46
+ npm run dev # Watch mode
47
+ npm run serve # Dev server on port 4460
48
+ npm start # Run MCP server directly
49
+ ```
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,7 @@
1
+ export interface AnnotationResult {
2
+ imagePath: string;
3
+ }
4
+ export declare function startAnnotationServer(port?: number): Promise<number>;
5
+ export declare function waitForAnnotation(): Promise<AnnotationResult>;
6
+ export declare function getServerPort(): number;
7
+ export declare function stopAnnotationServer(): void;
@@ -0,0 +1,230 @@
1
+ import express from 'express';
2
+ import { createServer } from 'node:http';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { writeFile, mkdir } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { exec } from 'node:child_process';
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+ const PROJECT_ROOT = resolve(__dirname, '..');
11
+ const PUBLIC_DIR = join(PROJECT_ROOT, 'public');
12
+ const SCREENSHOTS_DIR = join(PROJECT_ROOT, 'screenshots');
13
+ const LANDING_DIR = join(PROJECT_ROOT, 'landing');
14
+ let pendingResolve = null;
15
+ let server = null;
16
+ let serverPort = 0;
17
+ let proxyTarget = null;
18
+ export async function startAnnotationServer(port = 4461) {
19
+ if (server)
20
+ return serverPort;
21
+ await mkdir(SCREENSHOTS_DIR, { recursive: true });
22
+ const app = express();
23
+ // Serve landing page at root
24
+ app.get('/', (_req, res) => {
25
+ const landingIndex = join(LANDING_DIR, 'index.html');
26
+ if (existsSync(landingIndex)) {
27
+ res.sendFile(landingIndex);
28
+ }
29
+ else {
30
+ res.redirect('/annotate');
31
+ }
32
+ });
33
+ // Serve landing page static files
34
+ app.use('/landing', express.static(LANDING_DIR));
35
+ // Proxy route: fetch any URL and inject the overlay script
36
+ app.get('/proxy', async (req, res) => {
37
+ const targetUrl = req.query.url;
38
+ if (!targetUrl) {
39
+ res.status(400).send('Missing ?url= parameter');
40
+ return;
41
+ }
42
+ try {
43
+ // Store target origin for catch-all proxy
44
+ const parsedTarget = new URL(targetUrl);
45
+ proxyTarget = parsedTarget.origin;
46
+ const response = await fetch(targetUrl);
47
+ let html = await response.text();
48
+ const contentType = response.headers.get('content-type') || 'text/html';
49
+ if (contentType.includes('text/html')) {
50
+ // Inject overlay script before </body> (use absolute URL to avoid <base> tag redirect)
51
+ const overlayScript = `<script src="http://localhost:${serverPort}/overlay.js?v=${Date.now()}"></script>`;
52
+ if (html.includes('</body>')) {
53
+ html = html.replace('</body>', `${overlayScript}\n</body>`);
54
+ }
55
+ else {
56
+ html += overlayScript;
57
+ }
58
+ // Add <base> so the app's own assets and API calls resolve to the target origin
59
+ const url = new URL(targetUrl);
60
+ const base = `<base href="${url.origin}/">`;
61
+ if (html.includes('<head>')) {
62
+ html = html.replace('<head>', `<head>\n${base}`);
63
+ }
64
+ else if (html.includes('<html')) {
65
+ html = html.replace(/<html[^>]*>/, `$&\n<head>${base}</head>`);
66
+ }
67
+ }
68
+ res.type('text/html').send(html);
69
+ }
70
+ catch (err) {
71
+ res.status(500).send(`Failed to fetch ${targetUrl}`);
72
+ }
73
+ });
74
+ // Annotation app at /annotate
75
+ app.get('/annotate', (_req, res) => {
76
+ res.sendFile(join(PUBLIC_DIR, 'index.html'));
77
+ });
78
+ // Static files for annotation app
79
+ app.use(express.static(PUBLIC_DIR, { etag: false, lastModified: false, maxAge: 0 }));
80
+ // Serve an image by path (restricted to screenshots directory)
81
+ app.get('/api/image', (req, res) => {
82
+ const imgPath = req.query.path;
83
+ if (!imgPath)
84
+ return res.status(400).json({ error: 'No path provided' });
85
+ const resolved = resolve(imgPath);
86
+ if (!resolved.startsWith(SCREENSHOTS_DIR)) {
87
+ return res.status(403).json({ error: 'Access denied' });
88
+ }
89
+ if (!existsSync(resolved))
90
+ return res.status(404).json({ error: 'Image not found' });
91
+ res.sendFile(resolved);
92
+ });
93
+ // Save annotated image
94
+ app.post('/api/save', async (req, res) => {
95
+ try {
96
+ const chunks = [];
97
+ req.on('data', (chunk) => chunks.push(chunk));
98
+ await new Promise((resolve) => req.on('end', resolve));
99
+ const body = Buffer.concat(chunks);
100
+ const boundary = req.headers['content-type']?.split('boundary=')[1];
101
+ let imageBuffer;
102
+ if (boundary) {
103
+ const bodyStr = body.toString('latin1');
104
+ const parts = bodyStr.split('--' + boundary);
105
+ const imagePart = parts.find(p => p.includes('filename='));
106
+ if (!imagePart) {
107
+ res.status(400).json({ error: 'No image in request' });
108
+ return;
109
+ }
110
+ const headerEnd = imagePart.indexOf('\r\n\r\n');
111
+ const dataStart = headerEnd + 4;
112
+ const dataEnd = imagePart.lastIndexOf('\r\n');
113
+ imageBuffer = Buffer.from(imagePart.slice(dataStart, dataEnd), 'latin1');
114
+ }
115
+ else {
116
+ imageBuffer = body;
117
+ }
118
+ const filename = `annotated-${Date.now()}.png`;
119
+ const filepath = join(SCREENSHOTS_DIR, filename);
120
+ await writeFile(filepath, imageBuffer);
121
+ if (pendingResolve) {
122
+ pendingResolve({ imagePath: filepath });
123
+ pendingResolve = null;
124
+ }
125
+ res.json({ success: true, path: filepath });
126
+ }
127
+ catch (err) {
128
+ // Save error
129
+ res.status(500).json({ error: 'Failed to save' });
130
+ }
131
+ });
132
+ // Save annotated image from base64 JSON
133
+ app.post('/api/save-base64', express.json({ limit: '50mb' }), async (req, res) => {
134
+ try {
135
+ const { image, ...metadata } = req.body;
136
+ if (!image) {
137
+ res.status(400).json({ error: 'No image data' });
138
+ return;
139
+ }
140
+ const imageBuffer = Buffer.from(image, 'base64');
141
+ const filename = `annotated-${Date.now()}.png`;
142
+ const filepath = join(SCREENSHOTS_DIR, filename);
143
+ await writeFile(filepath, imageBuffer);
144
+ // Write all metadata alongside
145
+ const metaPath = filepath.replace('.png', '.json');
146
+ await writeFile(metaPath, JSON.stringify({
147
+ ...metadata,
148
+ timestamp: new Date().toISOString()
149
+ }, null, 2));
150
+ // Copy image to clipboard on macOS so user can Cmd+V in chat
151
+ if (process.platform === 'darwin') {
152
+ const safePath = filepath.replace(/'/g, "'\\''");
153
+ exec(`osascript -e 'set the clipboard to (read (POSIX file "${safePath}") as «class PNGf»)'`, (err) => {
154
+ if (err)
155
+ console.error('Clipboard copy failed:', err);
156
+ });
157
+ }
158
+ if (pendingResolve) {
159
+ pendingResolve({ imagePath: filepath });
160
+ pendingResolve = null;
161
+ }
162
+ res.json({ success: true, path: filepath });
163
+ }
164
+ catch (err) {
165
+ // Save error
166
+ res.status(500).json({ error: 'Failed to save' });
167
+ }
168
+ });
169
+ // Catch-all: proxy unknown requests to the target server
170
+ app.use(async (req, res) => {
171
+ if (!proxyTarget) {
172
+ res.status(404).send('Not found');
173
+ return;
174
+ }
175
+ try {
176
+ const targetUrl = `${proxyTarget}${req.originalUrl}`;
177
+ const headers = {};
178
+ for (const [key, value] of Object.entries(req.headers)) {
179
+ if (typeof value === 'string' && key !== 'host')
180
+ headers[key] = value;
181
+ }
182
+ const fetchRes = await fetch(targetUrl, {
183
+ method: req.method,
184
+ headers,
185
+ body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body,
186
+ });
187
+ res.status(fetchRes.status);
188
+ fetchRes.headers.forEach((value, key) => {
189
+ if (!['transfer-encoding', 'content-encoding', 'connection'].includes(key.toLowerCase())) {
190
+ res.setHeader(key, value);
191
+ }
192
+ });
193
+ const buffer = Buffer.from(await fetchRes.arrayBuffer());
194
+ res.send(buffer);
195
+ }
196
+ catch {
197
+ res.status(502).send('Proxy error');
198
+ }
199
+ });
200
+ return new Promise((resolve) => {
201
+ server = createServer(app);
202
+ server.listen(port, () => {
203
+ serverPort = port;
204
+ resolve(port);
205
+ });
206
+ server.on('error', () => {
207
+ server = createServer(app);
208
+ server.listen(0, () => {
209
+ const addr = server.address();
210
+ serverPort = typeof addr === 'object' && addr ? addr.port : port;
211
+ resolve(serverPort);
212
+ });
213
+ });
214
+ });
215
+ }
216
+ export function waitForAnnotation() {
217
+ return new Promise((resolve) => {
218
+ pendingResolve = resolve;
219
+ });
220
+ }
221
+ export function getServerPort() {
222
+ return serverPort;
223
+ }
224
+ export function stopAnnotationServer() {
225
+ if (server) {
226
+ server.close();
227
+ server = null;
228
+ serverPort = 0;
229
+ }
230
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ // Standalone dev server for previewing the annotation UI
2
+ import { startAnnotationServer } from './annotation-server.js';
3
+ const port = parseInt(process.argv[2] || '4460');
4
+ startAnnotationServer(port).then((actualPort) => {
5
+ console.log(`Blip server running at http://localhost:${actualPort}`);
6
+ console.log(` Annotation editor: http://localhost:${actualPort}/annotate`);
7
+ }).catch((err) => {
8
+ console.error('Failed to start server:', err);
9
+ process.exit(1);
10
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { startAnnotationServer, waitForAnnotation } from './annotation-server.js';
6
+ import { exec } from 'node:child_process';
7
+ import { existsSync } from 'node:fs';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = import.meta.dirname ?? join(__filename, '..');
13
+ function openBrowser(url) {
14
+ const cmd = process.platform === 'darwin' ? 'open' :
15
+ process.platform === 'win32' ? 'start' : 'xdg-open';
16
+ const child = exec(`${cmd} "${url}"`);
17
+ // Prevent child process output from corrupting MCP stdio transport
18
+ child.stdout?.destroy();
19
+ child.stderr?.destroy();
20
+ }
21
+ // Read image + metadata, return MCP content array with inline image
22
+ async function buildAnnotationResponse(imagePath, extra = {}) {
23
+ const imageData = await readFile(imagePath);
24
+ const base64 = imageData.toString('base64');
25
+ let metadata = {};
26
+ const metaPath = imagePath.replace('.png', '.json');
27
+ try {
28
+ if (existsSync(metaPath)) {
29
+ metadata = JSON.parse(await readFile(metaPath, 'utf-8'));
30
+ }
31
+ }
32
+ catch { }
33
+ return {
34
+ content: [
35
+ {
36
+ type: 'image',
37
+ data: base64,
38
+ mimeType: 'image/png',
39
+ },
40
+ {
41
+ type: 'text',
42
+ text: JSON.stringify({
43
+ annotated_image_path: imagePath,
44
+ metadata,
45
+ ...extra,
46
+ }),
47
+ },
48
+ ],
49
+ };
50
+ }
51
+ const server = new McpServer({
52
+ name: 'blip',
53
+ version: '0.1.0',
54
+ });
55
+ // Single tool: annotate a live page or open the standalone editor
56
+ server.tool('annotate', 'Open a visual annotation editor. The user can draw circles, arrows, highlights, and text on a screenshot. Returns the annotated image directly.', {
57
+ url: z.string().optional().describe('URL of a live page to annotate (e.g., http://localhost:3000). If omitted, opens the standalone editor where you can paste or drop a screenshot.'),
58
+ }, async ({ url: targetUrl }) => {
59
+ const port = await startAnnotationServer();
60
+ let browserUrl;
61
+ if (targetUrl) {
62
+ // Proxy mode: inject overlay on the live page
63
+ browserUrl = `http://localhost:${port}/proxy?url=${encodeURIComponent(targetUrl)}`;
64
+ }
65
+ else {
66
+ // Standalone editor mode
67
+ browserUrl = `http://localhost:${port}/annotate`;
68
+ }
69
+ openBrowser(browserUrl);
70
+ const result = await waitForAnnotation();
71
+ return buildAnnotationResponse(result.imagePath, targetUrl ? { source_url: targetUrl } : {});
72
+ });
73
+ async function main() {
74
+ const transport = new StdioServerTransport();
75
+ await server.connect(transport);
76
+ }
77
+ main().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "blip-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Visual annotation MCP server for Claude Code - draw on preview screenshots to communicate UI changes",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "blip": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "tsc --watch",
13
+ "start": "node dist/index.js",
14
+ "serve": "node dist/dev-server.js",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "blip",
20
+ "claude",
21
+ "claude-code",
22
+ "annotation",
23
+ "screenshot",
24
+ "visual-feedback",
25
+ "model-context-protocol",
26
+ "ai-coding"
27
+ ],
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/nebenzu/Blip"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "public",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.12.1",
44
+ "express": "^5.1.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/express": "^5.0.2",
48
+ "@types/node": "^22.15.3",
49
+ "typescript": "^5.8.3"
50
+ }
51
+ }