#!/usr/bin/env node
// Load environment variables from .env file if it exists
import dotenv from 'dotenv';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Try to load .env file from project root
const envPath = resolve(__dirname, '..', '.env');
if (existsSync(envPath)) {
const result = dotenv.config({ path: envPath });
if (result.error) {
console.error('Warning: Failed to load .env file:', result.error.message);
} else if (process.env.DEBUG === 'true') {
console.error('✓ Loaded .env file from:', envPath);
}
} else if (process.env.DEBUG === 'true') {
console.error('ℹ No .env file found at:', envPath);
console.error(' Using environment variables or defaults');
}
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
McpError,
ErrorCode,
} from '@modelcontextprotocol/sdk/types.js';
import { PaperWebSocketClient } from './paper-client.js';
import { PaperTools } from './tools.js';
import { ConnectionState } from './types.js';
// Validate and load environment variables
function getEnvVar(name: string, required = false, defaultValue?: string): string {
const value = process.env[name]?.trim();
if (required && (!value || value === '')) {
console.error(`\n❌ Error: ${name} environment variable is required`);
console.error(`\nPlease set ${name} in one of these ways:`);
console.error(` 1. Create a .env file in the project root with: ${name}=your_value`);
console.error(` 2. Set it as an environment variable: $env:${name}="your_value" (PowerShell)`);
console.error(` 3. Export it: export ${name}="your_value" (Bash)`);
console.error(`\nSee env.example for reference.\n`);
process.exit(1);
}
return value || defaultValue || '';
}
const DEBUG = process.env.DEBUG === 'true' || process.env.DEBUG === '1';
const PAPER_COOKIES = getEnvVar('PAPER_COOKIES', true);
const PAPER_DOCUMENT_ID = getEnvVar('PAPER_DOCUMENT_ID', false, '01KBWAZRVPXZZ1Y0EZ5FDT7ZYW');
// Validate cookie format
if (PAPER_COOKIES && !PAPER_COOKIES.includes('=')) {
console.error('\n❌ Error: PAPER_COOKIES appears to be invalid (should contain cookie values)');
console.error('Expected format: cookie1=value1; cookie2=value2; cookie3=value3\n');
process.exit(1);
}
// Initialize components
const wsClient = new PaperWebSocketClient(PAPER_DOCUMENT_ID, PAPER_COOKIES, DEBUG);
const tools = new PaperTools(PAPER_COOKIES, DEBUG);
tools.setWebSocketClient(wsClient);
// Create MCP server
const server = new Server(
{
name: 'paper-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// Connect WebSocket on startup
let wsConnected = false;
console.error('[Paper] 🔌 Connecting to WebSocket...');
wsClient.connect()
.then(() => {
wsConnected = true;
// Connection success is logged by the WebSocket client
})
.catch((error) => {
console.error('[Paper] ❌ Failed to connect WebSocket:', error);
// Continue anyway - tools will handle connection errors
});
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'list_user_documents',
description: 'Get the user\'s recent Paper documents',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'list_pages',
description: 'List all pages in the current Paper document',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'list_nodes',
description: 'List all nodes on the current Paper canvas',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'get_node',
description: 'Get details of a specific node by ID',
inputSchema: {
type: 'object',
properties: {
nodeId: {
type: 'string',
description: 'The ID of the node to retrieve',
},
},
required: ['nodeId'],
},
},
{
name: 'create_node',
description: 'Create a new shape on the Paper canvas. Supports Rectangle, Frame, Ellipse components. Can specify position, size, fill color, and which page to add it to.',
inputSchema: {
type: 'object',
properties: {
component: {
type: 'string',
description: 'Type of shape: Rectangle, Frame, or Ellipse',
enum: ['Rectangle', 'Frame', 'Ellipse'],
},
label: {
type: 'string',
description: 'Label/name for the node',
},
x: {
type: 'number',
description: 'X position of the node on canvas',
},
y: {
type: 'number',
description: 'Y position of the node on canvas',
},
pageIndex: {
type: 'number',
description: 'Which page to add the node to (1-based index). Defaults to 1 (first page). Use list_pages to see available pages.',
},
styles: {
type: 'object',
description: 'Style properties like width and height (as CSS strings, e.g. "100px")',
properties: {
width: { type: 'string', description: 'Width (e.g. "200px")' },
height: { type: 'string', description: 'Height (e.g. "150px")' },
},
},
styleMeta: {
type: 'object',
description: 'Visual styling like fill color. Use oklch color format with l (lightness 0-1), c (chroma 0-0.4), h (hue 0-360)',
},
},
},
},
{
name: 'update_node',
description: 'Update properties of an existing node',
inputSchema: {
type: 'object',
properties: {
nodeId: {
type: 'string',
description: 'The ID of the node to update',
},
},
required: ['nodeId'],
additionalProperties: true,
},
},
{
name: 'delete_node',
description: 'Delete a node from the canvas',
inputSchema: {
type: 'object',
properties: {
nodeId: {
type: 'string',
description: 'The ID of the node to delete',
},
},
required: ['nodeId'],
},
},
{
name: 'take_screenshot',
description: 'Take a screenshot of the Paper document. Captures the full page view including canvas. Returns the file path and base64 encoded image. Use list_pages first to get available page IDs.',
inputSchema: {
type: 'object',
properties: {
pageId: {
type: 'string',
description: 'The page ID to screenshot (from list_pages). If not specified, captures the default page.',
},
width: {
type: 'number',
description: 'Viewport width in pixels. Defaults to 1920.',
},
height: {
type: 'number',
description: 'Viewport height in pixels. Defaults to 1080.',
},
scale: {
type: 'number',
description: 'Device scale factor for higher resolution. Defaults to 1.',
},
format: {
type: 'string',
enum: ['png', 'jpeg'],
description: 'Image format. Defaults to png.',
},
quality: {
type: 'number',
description: 'JPEG quality (0-100). Only used when format is jpeg. Defaults to 90.',
},
fullPage: {
type: 'boolean',
description: 'Capture the full scrollable page instead of viewport. Defaults to false.',
},
outputPath: {
type: 'string',
description: 'Directory to save screenshots. Defaults to ./screenshots/',
},
},
},
},
],
};
});
// Helper function to truncate JSON output for logging
function truncateJson(obj: any, maxLength = 500): string {
const json = JSON.stringify(obj, null, 2);
if (json.length <= maxLength) {
return json;
}
return json.substring(0, maxLength) + `\n... (truncated, ${json.length - maxLength} more chars)`;
}
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const timestamp = new Date().toISOString();
console.error(`[${timestamp}] 📥 MCP Command: ${name}`);
if (args && Object.keys(args).length > 0) {
console.error(`[${timestamp}] Args:`, truncateJson(args, 200));
}
try {
switch (name) {
case 'list_user_documents': {
const result = await tools.listUserDocuments();
console.error(`[${timestamp}] ✅ Success - Found ${result.documents?.length || 0} documents`);
console.error(`[${timestamp}] Output:`, truncateJson(result, 300));
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
case 'list_pages': {
const result = await tools.listPages();
console.error(`[${timestamp}] ✅ Success - Found ${result.count} pages`);
console.error(`[${timestamp}] Output:`, truncateJson(result, 300));
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
case 'list_nodes': {
const result = await tools.listNodes();
const nodeCount = (result as any).nodes?.length || (result as any).count || 0;
console.error(`[${timestamp}] ✅ Success - Found ${nodeCount} nodes`);
console.error(`[${timestamp}] Output:`, truncateJson(result, 300));
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
case 'get_node': {
if (!args || typeof args.nodeId !== 'string') {
console.error(`[${timestamp}] ❌ Invalid arguments for get_node`);
throw new McpError(ErrorCode.InvalidParams, 'nodeId is required');
}
const result = await tools.getNode(args.nodeId);
console.error(`[${timestamp}] ✅ Success - Found node: ${args.nodeId}`);
console.error(`[${timestamp}] Output:`, truncateJson(result, 300));
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
case 'create_node': {
if (!args) {
console.error(`[${timestamp}] ❌ Invalid arguments for create_node`);
throw new McpError(ErrorCode.InvalidParams, 'Node data is required');
}
const { pageIndex, ...nodeData } = args as any;
const result = await tools.createNode(nodeData, pageIndex || 1);
console.error(`[${timestamp}] ✅ Success - Created node: ${result.node?.id || 'unknown'} on ${result.page || 'page 1'}`);
console.error(`[${timestamp}] Output:`, truncateJson(result, 300));
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
case 'update_node': {
if (!args || typeof args.nodeId !== 'string') {
console.error(`[${timestamp}] ❌ Invalid arguments for update_node`);
throw new McpError(ErrorCode.InvalidParams, 'nodeId is required');
}
const { nodeId, ...updates } = args;
const result = await tools.updateNode(nodeId, updates as any);
console.error(`[${timestamp}] ✅ Success - Updated node: ${nodeId}`);
console.error(`[${timestamp}] Output:`, truncateJson(result, 300));
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
case 'delete_node': {
if (!args || typeof args.nodeId !== 'string') {
console.error(`[${timestamp}] ❌ Invalid arguments for delete_node`);
throw new McpError(ErrorCode.InvalidParams, 'nodeId is required');
}
const result = await tools.deleteNode(args.nodeId);
console.error(`[${timestamp}] ✅ Success - Deleted node: ${args.nodeId}`);
console.error(`[${timestamp}] Output:`, truncateJson(result, 300));
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
case 'take_screenshot': {
const options = args || {};
const result = await tools.takeScreenshot(options as any);
if (result.success) {
console.error(`[${timestamp}] ✅ Success - Screenshot saved to: ${result.filePath}`);
} else {
console.error(`[${timestamp}] ❌ Screenshot failed: ${result.error}`);
}
// Return without base64 in the log to avoid huge output
const logResult = { ...result, base64: result.base64 ? `[${result.base64.length} chars]` : undefined };
console.error(`[${timestamp}] Output:`, truncateJson(logResult, 300));
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
default:
console.error(`[${timestamp}] ❌ Unknown tool: ${name}`);
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[${timestamp}] ❌ Failed: ${errorMessage}`);
throw new McpError(ErrorCode.InternalError, `Tool execution failed: ${errorMessage}`);
}
});
// Handle graceful shutdown
process.on('SIGINT', async () => {
await tools.closeScreenshot();
wsClient.disconnect();
process.exit(0);
});
process.on('SIGTERM', async () => {
await tools.closeScreenshot();
wsClient.disconnect();
process.exit(0);
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[Paper] 🚀 MCP server started and ready');
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});