#!/usr/bin/env node
/**
* Riddle MCP Server
*
* Provides screenshot and browser automation tools via MCP protocol.
* Wraps the Riddle API (api.riddledc.com) for easy integration with Claude Code.
*
* Core tools:
* - riddle_screenshot: Take screenshot of any URL
* - riddle_batch_screenshot: Screenshot multiple URLs
* - riddle_run_script: Run Playwright script (async)
* - riddle_get_job: Check job status and get artifacts
* - riddle_automate: Full sync automation with console logs and HAR
* - riddle_click_and_screenshot: Simple click + screenshot
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { writeFileSync } from "fs";
const RIDDLE_API = "https://api.riddledc.com";
// Save image to /tmp and return path
function saveToTmp(buffer, name) {
const path = `/tmp/${name}.png`;
writeFileSync(path, buffer);
return path;
}
class RiddleClient {
constructor() {
this.apiKey = process.env.RIDDLE_API_KEY;
if (!this.apiKey) {
console.error("Warning: RIDDLE_API_KEY not set");
}
}
async screenshotSync(url, viewport) {
const response = await fetch(`${RIDDLE_API}/v1/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url, viewport }),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const buffer = await response.arrayBuffer();
return Buffer.from(buffer).toString("base64");
}
async runScript(url, script, viewport) {
const response = await fetch(`${RIDDLE_API}/v1/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url, script, viewport, sync: false }),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}
async getJob(jobId) {
const response = await fetch(`${RIDDLE_API}/v1/jobs/${jobId}`, {
headers: { Authorization: `Bearer ${this.apiKey}` },
});
return response.json();
}
async getArtifacts(jobId) {
const response = await fetch(`${RIDDLE_API}/v1/jobs/${jobId}/artifacts`, {
headers: { Authorization: `Bearer ${this.apiKey}` },
});
return response.json();
}
// Wait for job to complete
async waitForJob(jobId, maxAttempts = 30, intervalMs = 2000) {
for (let i = 0; i < maxAttempts; i++) {
const job = await this.getJob(jobId);
if (job.status === "completed" || job.status === "complete") {
return job;
}
if (job.status === "failed") {
throw new Error(`Job failed: ${job.error || "Unknown error"}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error("Job timed out");
}
// Download artifact from URL
async downloadArtifact(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
// Run script and wait for all artifacts (full automation)
async runScriptSync(url, script, viewport, timeoutSec = 60) {
const response = await fetch(`${RIDDLE_API}/v1/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
script,
viewport,
sync: false,
timeout_sec: timeoutSec,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`API error ${response.status}: ${text}`);
}
const { job_id } = await response.json();
if (!job_id) throw new Error("No job_id returned");
// Wait for completion
await this.waitForJob(job_id);
// Get artifacts
const artifacts = await this.getArtifacts(job_id);
const results = [];
let consoleLogs = null;
let networkHar = null;
for (const artifact of artifacts.artifacts || []) {
if (artifact.url) {
const buffer = await this.downloadArtifact(artifact.url);
// Parse console.json
if (artifact.name === "console.json") {
try {
consoleLogs = JSON.parse(buffer.toString("utf8"));
} catch (e) {}
}
// Parse network.har
else if (artifact.name === "network.har") {
try {
networkHar = JSON.parse(buffer.toString("utf8"));
} catch (e) {}
}
results.push({
name: artifact.name,
type: artifact.name?.endsWith(".png") ? "image" : "file",
buffer,
});
}
}
return { job_id, artifacts: results, consoleLogs, networkHar };
}
}
// Create server
const server = new Server(
{ name: "riddle-mcp-server", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
const client = new RiddleClient();
// Device presets
const devices = {
desktop: { width: 1280, height: 720 },
ipad: { width: 820, height: 1180 },
iphone: { width: 390, height: 844 },
};
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "riddle_screenshot",
description:
"Take a screenshot of a URL using the Riddle API. Returns base64-encoded PNG image.",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "URL to screenshot" },
width: {
type: "number",
description: "Viewport width (default: 1280)",
},
height: {
type: "number",
description: "Viewport height (default: 720)",
},
device: {
type: "string",
enum: ["desktop", "ipad", "iphone"],
description: "Device preset (overrides width/height)",
},
},
required: ["url"],
},
},
{
name: "riddle_batch_screenshot",
description: "Screenshot multiple URLs. Returns array of base64 images.",
inputSchema: {
type: "object",
properties: {
urls: {
type: "array",
items: { type: "string" },
description: "URLs to screenshot",
},
device: {
type: "string",
enum: ["desktop", "ipad", "iphone"],
},
},
required: ["urls"],
},
},
{
name: "riddle_run_script",
description:
"Run a Playwright script on a page (async). Returns job_id to check status later.",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "Starting URL" },
script: {
type: "string",
description: "Playwright script (page object available)",
},
width: { type: "number" },
height: { type: "number" },
},
required: ["url", "script"],
},
},
{
name: "riddle_get_job",
description: "Get status and artifacts of a Riddle job",
inputSchema: {
type: "object",
properties: {
job_id: { type: "string", description: "Job ID to check" },
},
required: ["job_id"],
},
},
{
name: "riddle_automate",
description:
"Run a Playwright script, wait for completion, and return all artifacts. Includes console logs and network HAR. Full sync automation - one call does everything.",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "Starting URL" },
script: {
type: "string",
description:
"Playwright script. Use 'page' object. Example: await page.click('button'); await page.screenshot({path: 'result.png'});",
},
device: {
type: "string",
enum: ["desktop", "ipad", "iphone"],
description: "Device preset",
},
timeout_sec: {
type: "number",
description: "Max execution time in seconds (default: 60)",
},
},
required: ["url", "script"],
},
},
{
name: "riddle_click_and_screenshot",
description:
"Simple automation: load URL, click a selector, take screenshot. Good for testing button clicks, game starts, etc.",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "URL to load" },
click: {
type: "string",
description: "CSS selector to click (e.g., 'button.start', '.play-btn')",
},
wait_ms: {
type: "number",
description: "Wait time after click before screenshot (default: 1000)",
},
device: {
type: "string",
enum: ["desktop", "ipad", "iphone"],
},
},
required: ["url", "click"],
},
},
],
}));
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (name === "riddle_screenshot") {
const viewport = args.device
? devices[args.device]
: { width: args.width || 1280, height: args.height || 720 };
const base64 = await client.screenshotSync(args.url, viewport);
return {
content: [
{
type: "image",
data: base64,
mimeType: "image/png",
},
],
};
}
if (name === "riddle_batch_screenshot") {
const viewport = args.device ? devices[args.device] : devices.desktop;
const results = [];
for (const url of args.urls) {
try {
const base64 = await client.screenshotSync(url, viewport);
results.push({ url, success: true, image: base64 });
} catch (e) {
results.push({ url, success: false, error: e.message });
}
}
return {
content: [
{
type: "text",
text: JSON.stringify(
results.map((r) => ({
url: r.url,
success: r.success,
error: r.error,
})),
null,
2
),
},
...results
.filter((r) => r.success)
.map((r) => ({
type: "image",
data: r.image,
mimeType: "image/png",
})),
],
};
}
if (name === "riddle_run_script") {
const viewport = { width: args.width || 1280, height: args.height || 720 };
const result = await client.runScript(args.url, args.script, viewport);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
}
if (name === "riddle_get_job") {
const job = await client.getJob(args.job_id);
const artifacts = await client.getArtifacts(args.job_id);
return {
content: [
{
type: "text",
text: JSON.stringify({ job, artifacts }, null, 2),
},
],
};
}
if (name === "riddle_automate") {
const viewport = args.device ? devices[args.device] : devices.desktop;
const { job_id, artifacts, consoleLogs, networkHar } = await client.runScriptSync(
args.url,
args.script,
viewport,
args.timeout_sec || 60
);
const images = [];
const savedPaths = [];
for (const artifact of artifacts) {
if (artifact.type === "image") {
const base64 = artifact.buffer.toString("base64");
images.push({ type: "image", data: base64, mimeType: "image/png" });
const path = saveToTmp(artifact.buffer, artifact.name.replace(".png", ""));
savedPaths.push(path);
}
}
// Format console logs for readability
let consoleOutput = null;
if (consoleLogs?.entries) {
consoleOutput = {
summary: consoleLogs.summary,
logs: consoleLogs.entries.log?.slice(-20) || [],
errors: consoleLogs.entries.error || [],
warns: consoleLogs.entries.warn || [],
};
}
// Format network HAR summary
let networkSummary = null;
if (networkHar?.log?.entries) {
const entries = networkHar.log.entries;
networkSummary = {
total_requests: entries.length,
failed: entries.filter((e) => e.response?.status >= 400).length,
requests: entries.slice(-10).map((e) => ({
url: e.request?.url?.substring(0, 80),
status: e.response?.status,
time: e.time,
})),
};
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
job_id,
saved_to: savedPaths,
console: consoleOutput,
network: networkSummary,
},
null,
2
),
},
...images,
],
};
}
if (name === "riddle_click_and_screenshot") {
const viewport = args.device ? devices[args.device] : devices.desktop;
const waitMs = args.wait_ms || 1000;
const script = `
await page.waitForLoadState('networkidle');
await page.click('${args.click.replace(/'/g, "\\'")}');
await page.waitForTimeout(${waitMs});
await page.screenshot({ path: 'after-click.png', fullPage: false });
`;
const { job_id, artifacts } = await client.runScriptSync(
args.url,
script,
viewport,
30
);
const images = [];
for (const artifact of artifacts) {
if (artifact.type === "image") {
const base64 = artifact.buffer.toString("base64");
images.push({ type: "image", data: base64, mimeType: "image/png" });
saveToTmp(artifact.buffer, "click-result");
}
}
return {
content: [
{
type: "text",
text: JSON.stringify({ job_id, clicked: args.click }, null, 2),
},
...images,
],
};
}
throw new Error(`Unknown tool: ${name}`);
} catch (error) {
return {
content: [{ type: "text", text: `Error: ${error.message}` }],
isError: true,
};
}
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Riddle MCP server running");
}
main().catch(console.error);