5 tools (capture_screenshot, markdown_to_pdf, markdown_to_image, markdown_to_html, api_status) + API docs resource. Stdio transport for Claude Code/Desktop integration. Updated README with MCP setup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
319 lines
9.2 KiB
JavaScript
Executable File
319 lines
9.2 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* OpSpawn Screenshot API - MCP Server
|
|
*
|
|
* Exposes screenshot capture and markdown conversion as MCP tools
|
|
* that LLM agents can use via the Model Context Protocol.
|
|
*
|
|
* Usage:
|
|
* node mcp-server.mjs # stdio transport (for Claude Code, etc.)
|
|
* SNAPAPI_URL=http://localhost:3001 node mcp-server.mjs # custom API URL
|
|
*
|
|
* Add to claude_desktop_config.json:
|
|
* {
|
|
* "mcpServers": {
|
|
* "snapapi": {
|
|
* "command": "node",
|
|
* "args": ["/path/to/mcp-server.mjs"]
|
|
* }
|
|
* }
|
|
* }
|
|
*/
|
|
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
import { z } from 'zod';
|
|
|
|
const API_URL = process.env.SNAPAPI_URL || 'http://localhost:3001';
|
|
const API_KEY = process.env.SNAPAPI_API_KEY || '';
|
|
|
|
async function apiRequest(path, options = {}) {
|
|
const url = new URL(path, API_URL);
|
|
|
|
if (options.params) {
|
|
for (const [k, v] of Object.entries(options.params)) {
|
|
if (v !== undefined && v !== null && v !== '') {
|
|
url.searchParams.set(k, String(v));
|
|
}
|
|
}
|
|
}
|
|
|
|
const headers = {};
|
|
if (API_KEY) headers['X-API-Key'] = API_KEY;
|
|
if (options.body) headers['Content-Type'] = 'application/json';
|
|
|
|
const resp = await fetch(url.toString(), {
|
|
method: options.method || 'GET',
|
|
headers,
|
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
});
|
|
|
|
return resp;
|
|
}
|
|
|
|
const server = new McpServer({
|
|
name: 'snapapi',
|
|
version: '1.0.0',
|
|
});
|
|
|
|
// Tool 1: Capture screenshot from URL
|
|
server.tool(
|
|
'capture_screenshot',
|
|
'Capture a screenshot or PDF of a web page. Returns the image as base64-encoded data.',
|
|
{
|
|
url: z.string().url().describe('The URL to capture'),
|
|
format: z.enum(['png', 'jpeg', 'pdf']).default('png').describe('Output format'),
|
|
width: z.number().int().min(320).max(3840).default(1280).optional()
|
|
.describe('Viewport width in pixels'),
|
|
height: z.number().int().min(200).max(2160).default(800).optional()
|
|
.describe('Viewport height in pixels'),
|
|
fullPage: z.boolean().default(false).optional()
|
|
.describe('Capture full scrollable page instead of just viewport'),
|
|
delay: z.number().int().min(0).max(10000).default(0).optional()
|
|
.describe('Milliseconds to wait after page load before capture'),
|
|
},
|
|
async ({ url, format, width, height, fullPage, delay }) => {
|
|
try {
|
|
const resp = await apiRequest('/api/capture', {
|
|
params: { url, format, width, height, fullPage, delay },
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
|
return {
|
|
content: [{ type: 'text', text: `Error ${resp.status}: ${err.error || resp.statusText}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
const mimeType = format === 'pdf' ? 'application/pdf'
|
|
: format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
|
|
if (format === 'pdf') {
|
|
return {
|
|
content: [{
|
|
type: 'resource',
|
|
resource: {
|
|
uri: `data:${mimeType};base64,${buffer.toString('base64')}`,
|
|
mimeType,
|
|
text: buffer.toString('base64'),
|
|
},
|
|
}],
|
|
};
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'image',
|
|
data: buffer.toString('base64'),
|
|
mimeType,
|
|
}],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
content: [{ type: 'text', text: `Failed to capture screenshot: ${err.message}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Tool 2: Convert Markdown to PDF
|
|
server.tool(
|
|
'markdown_to_pdf',
|
|
'Convert Markdown text to a styled PDF document. Returns base64-encoded PDF.',
|
|
{
|
|
markdown: z.string().describe('Markdown content to convert'),
|
|
theme: z.enum(['light', 'dark']).default('light').optional()
|
|
.describe('Visual theme for the PDF'),
|
|
paperSize: z.enum(['A4', 'Letter', 'Legal', 'Tabloid']).default('A4').optional()
|
|
.describe('Paper size'),
|
|
landscape: z.boolean().default(false).optional()
|
|
.describe('Use landscape orientation'),
|
|
fontSize: z.string().default('16px').optional()
|
|
.describe('Base font size (e.g. "14px", "18px")'),
|
|
},
|
|
async ({ markdown, theme, paperSize, landscape, fontSize }) => {
|
|
try {
|
|
const resp = await apiRequest('/api/md2pdf', {
|
|
method: 'POST',
|
|
body: { markdown, theme, paperSize, landscape, fontSize },
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
|
return {
|
|
content: [{ type: 'text', text: `Error ${resp.status}: ${err.error || resp.statusText}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
return {
|
|
content: [{
|
|
type: 'resource',
|
|
resource: {
|
|
uri: `data:application/pdf;base64,${buffer.toString('base64')}`,
|
|
mimeType: 'application/pdf',
|
|
text: buffer.toString('base64'),
|
|
},
|
|
}],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
content: [{ type: 'text', text: `Failed to convert markdown to PDF: ${err.message}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Tool 3: Convert Markdown to PNG image
|
|
server.tool(
|
|
'markdown_to_image',
|
|
'Convert Markdown text to a styled PNG or JPEG image. Returns the image as base64.',
|
|
{
|
|
markdown: z.string().describe('Markdown content to convert'),
|
|
format: z.enum(['png', 'jpeg']).default('png').optional()
|
|
.describe('Image format'),
|
|
theme: z.enum(['light', 'dark']).default('light').optional()
|
|
.describe('Visual theme'),
|
|
width: z.number().int().min(320).max(3840).default(1280).optional()
|
|
.describe('Image width in pixels'),
|
|
fontSize: z.string().default('16px').optional()
|
|
.describe('Base font size'),
|
|
},
|
|
async ({ markdown, format, theme, width, fontSize }) => {
|
|
try {
|
|
const resp = await apiRequest('/api/md2png', {
|
|
method: 'POST',
|
|
body: { markdown, format, theme, width, fontSize },
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({ error: resp.statusText }));
|
|
return {
|
|
content: [{ type: 'text', text: `Error ${resp.status}: ${err.error || resp.statusText}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
return {
|
|
content: [{
|
|
type: 'image',
|
|
data: buffer.toString('base64'),
|
|
mimeType,
|
|
}],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
content: [{ type: 'text', text: `Failed to convert markdown to image: ${err.message}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Tool 4: Convert Markdown to HTML (free, no auth)
|
|
server.tool(
|
|
'markdown_to_html',
|
|
'Convert Markdown text to styled HTML. Free, no authentication required.',
|
|
{
|
|
markdown: z.string().describe('Markdown content to convert'),
|
|
theme: z.enum(['light', 'dark']).default('light').optional()
|
|
.describe('Visual theme'),
|
|
fontSize: z.string().default('16px').optional()
|
|
.describe('Base font size'),
|
|
},
|
|
async ({ markdown, theme, fontSize }) => {
|
|
try {
|
|
const resp = await apiRequest('/api/md2html', {
|
|
method: 'POST',
|
|
body: { markdown, theme, fontSize },
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const err = await resp.text();
|
|
return {
|
|
content: [{ type: 'text', text: `Error ${resp.status}: ${err}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const html = await resp.text();
|
|
return {
|
|
content: [{ type: 'text', text: html }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
content: [{ type: 'text', text: `Failed to convert markdown to HTML: ${err.message}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Tool 5: Get API status
|
|
server.tool(
|
|
'api_status',
|
|
'Check the SnapAPI service health, stats, and current load.',
|
|
{},
|
|
async () => {
|
|
try {
|
|
const resp = await apiRequest('/api/status');
|
|
const data = await resp.json();
|
|
return {
|
|
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
content: [{ type: 'text', text: `Failed to get status: ${err.message}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Resource: API documentation
|
|
server.resource(
|
|
'api-docs',
|
|
'snapapi://docs',
|
|
{ description: 'SnapAPI documentation and endpoint reference' },
|
|
async () => {
|
|
try {
|
|
const resp = await apiRequest('/api');
|
|
const data = await resp.json();
|
|
return {
|
|
contents: [{
|
|
uri: 'snapapi://docs',
|
|
mimeType: 'application/json',
|
|
text: JSON.stringify(data, null, 2),
|
|
}],
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
contents: [{
|
|
uri: 'snapapi://docs',
|
|
mimeType: 'text/plain',
|
|
text: `Failed to fetch docs: ${err.message}`,
|
|
}],
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
// Start server
|
|
async function main() {
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
process.stderr.write(`MCP server error: ${err.message}\n`);
|
|
process.exit(1);
|
|
});
|