#!/usr/bin/env python3
"""
MCP Server for Cover Letter Generation
Generates PDF cover letters from JSON input using LaTeX with folder management
"""
import asyncio
import json
import os
import subprocess
import tempfile
import uuid
from pathlib import Path
import re
import shutil
import mcp.types as types
from mcp.server import Server
from mcp.server.stdio import stdio_server
# Embedded LaTeX template - matching resume design style
LATEX_TEMPLATE = r"""
\documentclass[11pt,letterpaper]{{article}}
\usepackage[utf8]{{inputenc}}
\usepackage[margin=1in]{{geometry}}
\usepackage{{setspace}}
\usepackage{{titlesec}}
% Set up fonts and spacing to match resume
\setstretch{{1.1}}
\setlength{{\parindent}}{{0pt}}
\setlength{{\parskip}}{{0.8em}}
% Define section formatting to match resume style
\titleformat{{\section}}{{\large\bfseries}}{{}}{{0pt}}{{}}[\titlerule]
\begin{{document}}
% Header - centered name matching resume style
\begin{{center}}
\textbf{{\Large {name}}}\\
\vspace{{0.2cm}}
\today
\end{{center}}
\vspace{{0.5cm}}
% Recipient information
{company}\\
Hiring Manager
\vspace{{0.5cm}}
% Subject line
\textbf{{Re: Application for {position}}}
\vspace{{0.4cm}}
% Salutation
Dear Hiring Manager,
% Body content
{body}
\vspace{{0.4cm}}
% Closing
Sincerely,
\vspace{{0.4cm}}
{name}
\end{{document}}
"""
# Directory for saving PDFs (mounted volume)
PDF_OUTPUT_DIR = "/downloads"
# Initialize MCP server
app = Server("cover-letter-generator")
@app.list_tools()
async def list_tools() -> list[types.Tool]:
"""List available tools"""
return [
types.Tool(
name="generate_cover_letter",
description="Generate a PDF cover letter from JSON data and save it to the downloads folder. Can save to custom folders within the downloads directory.",
inputSchema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Full name of the applicant"
},
"company": {
"type": "string",
"description": "Company name"
},
"position": {
"type": "string",
"description": "Position being applied for"
},
"body": {
"type": "string",
"description": "Main body text of the cover letter"
},
"filename": {
"type": "string",
"description": "Optional custom filename (without .pdf extension). If not provided, will auto-generate from name and company.",
"default": ""
},
"folderPath": {
"type": "string",
"description": "Optional folder path within the downloads directory. If not specified, saves to the root directory. If the folder doesn't exist, it will be created. Example: 'job-applications/google' or 'drafts'"
}
},
"required": ["name", "company", "position", "body"]
}
),
types.Tool(
name="create_folder",
description="Create a new folder within the downloads directory for organizing cover letters",
inputSchema={
"type": "object",
"properties": {
"folderPath": {
"type": "string",
"description": "Folder path to create within the downloads directory. Can include nested folders. Example: 'job-applications/google' or 'personal-projects'"
}
},
"required": ["folderPath"]
}
),
types.Tool(
name="list_folders",
description="List all folders and files in the downloads directory to help with organization",
inputSchema={
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Optional path within downloads to list. If not specified, lists the root directory",
"default": ""
}
}
}
)
]
@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
"""Handle tool calls"""
if name == "generate_cover_letter":
return await generate_cover_letter(arguments)
elif name == "create_folder":
return await create_folder(arguments)
elif name == "list_folders":
return await list_folders(arguments)
else:
raise ValueError(f"Unknown tool: {name}")
@app.list_resources()
async def list_resources() -> list[types.Resource]:
"""List available resources (generated PDFs in downloads folder)"""
resources = []
# Ensure downloads directory exists
os.makedirs(PDF_OUTPUT_DIR, exist_ok=True)
# Recursively list all PDF files in downloads directory and subdirectories
try:
for root, dirs, files in os.walk(PDF_OUTPUT_DIR):
for filename in files:
if filename.endswith('.pdf'):
file_path = os.path.join(root, filename)
file_size = os.path.getsize(file_path)
# Get relative path from downloads directory
rel_path = os.path.relpath(file_path, PDF_OUTPUT_DIR)
resources.append(types.Resource(
uri=f"cover-letter://{rel_path.replace(os.sep, '/')}",
name=rel_path,
description=f"Generated cover letter PDF ({file_size/1024:.1f} KB)"
))
except Exception as e:
# Directory doesn't exist or is not accessible
pass
return resources
@app.read_resource()
async def read_resource(uri: str) -> str:
"""Read a resource (PDF file from downloads folder)"""
if not uri.startswith("cover-letter://"):
raise ValueError(f"Unknown resource URI: {uri}")
relative_path = uri.replace("cover-letter://", "").replace('/', os.sep)
file_path = os.path.join(PDF_OUTPUT_DIR, relative_path)
if not os.path.exists(file_path):
raise ValueError(f"File not found: {relative_path}")
if not relative_path.endswith('.pdf'):
raise ValueError(f"File is not a PDF: {relative_path}")
try:
# Read PDF as binary and encode as base64
with open(file_path, "rb") as f:
pdf_content = f.read()
# Return base64 encoded content
import base64
return base64.b64encode(pdf_content).decode('utf-8')
except Exception as e:
raise ValueError(f"Error reading file: {str(e)}")
def sanitize_path(input_path: str) -> str:
"""Sanitize path to prevent directory traversal and invalid characters"""
if not input_path:
return ""
# Normalize the path
normalized_path = os.path.normpath(input_path)
# Remove leading slashes and dots
sanitized = re.sub(r'^[/\\\.]+', '', normalized_path)
# Replace any remaining .. sequences
sanitized = re.sub(r'\.\.', '', sanitized)
# Replace invalid characters with underscores
sanitized = re.sub(r'[<>:"|?*]', '_', sanitized)
# Convert to forward slashes for consistency
sanitized = sanitized.replace('\\', '/')
return sanitized
def escape_latex(text: str) -> str:
"""Escape special LaTeX characters in text"""
# Define LaTeX special characters and their escapes
latex_special_chars = {
'&': r'\&',
'%': r'\%',
'$': r'\$',
'#': r'\#',
'^': r'\textasciicircum{}',
'_': r'\_',
'{': r'\{',
'}': r'\}',
'~': r'\textasciitilde{}',
'\\': r'\textbackslash{}',
}
for char, escape in latex_special_chars.items():
text = text.replace(char, escape)
return text
def sanitize_filename(text: str) -> str:
"""Sanitize text for use in filename"""
# Replace spaces and special characters
# Keep only alphanumeric, spaces, hyphens, and underscores
sanitized = re.sub(r'[^\w\s\-_]', '', text)
# Replace spaces with underscores
sanitized = re.sub(r'\s+', '_', sanitized)
# Remove multiple underscores
sanitized = re.sub(r'_+', '_', sanitized)
# Remove leading/trailing underscores
sanitized = sanitized.strip('_')
return sanitized
async def create_folder(arguments: dict) -> list[types.TextContent]:
"""Create a new folder within the downloads directory"""
try:
folder_path = arguments["folderPath"]
if not folder_path or folder_path.strip() == "":
raise ValueError("Folder path cannot be empty")
# Sanitize the folder path
sanitized_path = sanitize_path(folder_path)
full_path = os.path.join(PDF_OUTPUT_DIR, sanitized_path)
# Create the folder
os.makedirs(full_path, exist_ok=True)
relative_path = os.path.relpath(full_path, PDF_OUTPUT_DIR)
return [types.TextContent(
type="text",
text=f"ā
**Folder created successfully!**\n\n" +
f"š **Folder path:** {relative_path}\n" +
f"š **Full path:** {full_path}\n\n" +
f"You can now save cover letters to this folder by specifying the folderPath parameter."
)]
except Exception as e:
return [types.TextContent(
type="text",
text=f"ā **Error creating folder:** {str(e)}\n\n" +
f"Please check that the folder path is valid and you have write permissions."
)]
async def list_folders(arguments: dict) -> list[types.TextContent]:
"""List all folders and files in the downloads directory"""
try:
sub_path = arguments.get("path", "")
# Sanitize the path
sanitized_path = sanitize_path(sub_path)
full_path = os.path.join(PDF_OUTPUT_DIR, sanitized_path)
# Ensure the directory exists
os.makedirs(full_path, exist_ok=True)
# Read directory contents
items = os.listdir(full_path)
folder_list = ""
file_list = ""
for item in items:
item_path = os.path.join(sanitized_path, item) if sanitized_path else item
full_item_path = os.path.join(full_path, item)
if os.path.isdir(full_item_path):
folder_list += f"š {item_path}/\n"
elif item.endswith(".pdf"):
stat = os.stat(full_item_path)
size = stat.st_size / 1024
from datetime import datetime
date = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d")
file_list += f"š {item_path} ({size:.2f} KB, {date})\n"
current_path = sanitized_path or "root"
result = f"š **Contents of {current_path}:**\n\n"
if folder_list:
result += f"**Folders:**\n{folder_list}\n"
if file_list:
result += f"**Cover Letter PDFs:**\n{file_list}\n"
if not folder_list and not file_list:
result += f"The directory is empty.\n"
result += f"\nš” **Tip:** Use the folderPath parameter in generate_cover_letter to save PDFs to specific folders."
return [types.TextContent(
type="text",
text=result
)]
except Exception as e:
return [types.TextContent(
type="text",
text=f"ā **Error listing directory:** {str(e)}\n\n" +
f"Please check that the path exists and you have read permissions."
)]
async def generate_cover_letter(arguments: dict) -> list[types.TextContent]:
"""Generate PDF cover letter from JSON data and save to downloads folder"""
try:
# Ensure downloads directory exists
os.makedirs(PDF_OUTPUT_DIR, exist_ok=True)
# Extract and escape data from arguments
name = escape_latex(arguments["name"])
company = escape_latex(arguments["company"])
position = escape_latex(arguments["position"])
body = escape_latex(arguments["body"])
custom_filename = arguments.get("filename", "")
folder_path = arguments.get("folderPath", "")
# Determine the output directory
output_dir = PDF_OUTPUT_DIR
if folder_path and folder_path.strip() != "":
sanitized_folder_path = sanitize_path(folder_path)
output_dir = os.path.join(PDF_OUTPUT_DIR, sanitized_folder_path)
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Generate filename
if custom_filename:
filename = f"{sanitize_filename(custom_filename)}.pdf"
else:
safe_name = sanitize_filename(arguments["name"])
safe_company = sanitize_filename(arguments["company"])
safe_position = sanitize_filename(arguments["position"])
filename = f"cover_letter_{safe_name}_{safe_company}_{safe_position}.pdf"
# Ensure filename is unique
base_filename = filename[:-4] # Remove .pdf
counter = 1
final_path = os.path.join(output_dir, filename)
while os.path.exists(final_path):
filename = f"{base_filename}_{counter}.pdf"
final_path = os.path.join(output_dir, filename)
counter += 1
# Fill LaTeX template
latex_content = LATEX_TEMPLATE.format(
name=name,
company=company,
position=position,
body=body
)
# Create temporary files for LaTeX compilation
with tempfile.TemporaryDirectory() as temp_dir:
temp_id = str(uuid.uuid4())
tex_file = os.path.join(temp_dir, f"{temp_id}.tex")
pdf_file = os.path.join(temp_dir, f"{temp_id}.pdf")
# Write LaTeX file
with open(tex_file, "w", encoding="utf-8") as f:
f.write(latex_content)
# Compile LaTeX to PDF (run twice for proper formatting)
for i in range(2):
result = subprocess.run(
["pdflatex", "-interaction=nonstopmode", "-output-directory", temp_dir, tex_file],
capture_output=True,
text=True
)
if result.returncode != 0:
# Include both stdout and stderr for better debugging
error_msg = f"LaTeX compilation failed:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}"
return [types.TextContent(
type="text",
text=error_msg
)]
# Check if PDF was created
if not os.path.exists(pdf_file):
return [types.TextContent(
type="text",
text="PDF file was not created successfully"
)]
# Move PDF to downloads folder
shutil.move(pdf_file, final_path)
# Get file size
file_size_kb = os.path.getsize(final_path) / 1024
# Get relative path for display
relative_path = os.path.relpath(final_path, PDF_OUTPUT_DIR)
return [types.TextContent(
type="text",
text=f"ā
Cover letter generated successfully!\n\n" +
f"š **File saved to:** {relative_path}\n" +
f"š **Full path:** {final_path}\n" +
f"š¤ **Applicant:** {arguments['name']}\n" +
f"š¢ **Company:** {arguments['company']}\n" +
f"š¼ **Position:** {arguments['position']}\n" +
f"š **File Size:** {file_size_kb:.1f} KB\n" +
f"š **Saved in folder:** {folder_path or 'root directory'}\n\n" +
f"š” The PDF is now available in your downloads folder!\n" +
f"š Resource URI: cover-letter://{relative_path.replace(os.sep, '/')}"
)]
except Exception as e:
return [types.TextContent(
type="text",
text=f"ā **Error generating cover letter:** {str(e)}\n\n" +
f"Please check:\n" +
f"⢠Your LaTeX input is valid\n" +
f"⢠That all required fields are filled\n" +
f"⢠The specified folder path is valid\n" +
f"⢠Docker container has write permissions"
)]
async def main():
"""Main entry point"""
async with stdio_server() as streams:
await app.run(
streams[0],
streams[1],
app.create_initialization_options()
)
if __name__ == "__main__":
asyncio.run(main())