Fun with MCP - an "O RLY?" Book Generator MCP Tool
Perfect to use at almost any moment in a software engineer's life, this tool generates "O RLY?" books from just a simple query.
Posted on July 12, 2025

tl;dr;
The generator is completely open source and available on GitHub: https://github.com/princefishthrower/orly-mcp
How I Did it
For a long time I've known of the "O'rly Generator" which is always fun to cook up some sarcastic book covers. The actual website work and design is from Arthur Beaulieu, but the actual original image generator code is from Charles Berlin.
I find all the modern LLMs have a pretty decent sense of humor, so I figured I'd hunt down a way to connect the same image generator as used on that website to an MCP tool. They're especially funny when you're talking or chatting to a collegue and want a book cover within just a few seconds.
Doing a little yack shaving, I ended up converting the whole script to Python 3, and allowing for a variable of image size as well. (Turns out, at least in Claude Desktop, there is a limit of 1048576 bytes for any tool response, so I had to make sure the image was small enough to fit that limit.)
Anyway, as of when that blog was written, the generation python script looks like this:
import json, os, re, requests, randomfrom PIL import Image, ImageDraw, ImageFontimport datetimefrom orly_generator.cache import get, set, clear # Import cache functions directlyfrom fontTools.ttLib import TTFontdef get_text_size(draw, text, font, multiline=False):"""Helper function to get text size compatible with both old and new Pillow versions"""if multiline:# For multiline text, we need to handle it differentlylines = text.split('\n')total_width = 0total_height = 0for line in lines:bbox = draw.textbbox((0, 0), line, font=font)line_width = bbox[2] - bbox[0]line_height = bbox[3] - bbox[1]total_width = max(total_width, line_width)total_height += line_heightreturn total_width, total_heightelse:bbox = draw.textbbox((0, 0), text, font=font)return bbox[2] - bbox[0], bbox[3] - bbox[1]def generate_image(title, topText, author, image_code, theme, guide_text_placement='bottom_right', guide_text='The Definitive Guide'):cache_string = title + "_" + topText + "_" + author + "_" + image_code + "_" + theme + "_" + guide_text_placement + "_" + guide_textcached = get(cache_string)if cached:print("Cache hit")try:final_path = os.path.abspath(os.path.join(os.path.dirname( __file__ ), ('%s.png'%datetime.datetime.now())))width = 500height = 700im = Image.frombytes('RGBA', (width, height), cached)im.save(final_path)im.close()return final_pathexcept Exception as e:print(str(e))else:print("Cache miss")themeColors = {"0" : (85,19,93,255),"1" : (113,112,110,255),"2" : (128,27,42,255),"3" : (184,7,33,255),"4" : (101,22,28,255),"5" : (80,61,189,255),"6" : (225,17,5,255),"7" : (6,123,176,255),"8" : (247,181,0,255),"9" : (0,15,118,255),"10" : (168,0,155,255),"11" : (0,132,69,255),"12" : (0,153,157,255),"13" : (1,66,132,255),"14" : (177,0,52,255),"15" : (55,142,25,255),"16" : (133,152,0,255),}themeColor = themeColors[theme]width = 500height = 700im = Image.new('RGBA', (width, height), "white")font_path = os.path.abspath(os.path.join(os.path.dirname( __file__ ), '..', 'fonts', 'Garamond Light.ttf'))font_path_helv = os.path.abspath(os.path.join(os.path.dirname( __file__ ), '..', 'fonts', 'HelveticaNeue-Medium.otf'))font_path_helv_bold = os.path.abspath(os.path.join(os.path.dirname( __file__ ), '..', 'fonts', 'Helvetica Bold.ttf'))font_path_italic = os.path.abspath(os.path.join(os.path.dirname( __file__ ), '..', 'fonts', 'Garamond LightItalic.ttf'))topFont = ImageFont.truetype(font_path_italic, 20)subtitleFont = ImageFont.truetype(font_path_italic, 34)authorFont = ImageFont.truetype(font_path_italic, 24)titleFont = ImageFont.truetype(font_path, 62)oriellyFont = ImageFont.truetype(font_path_helv, 28)questionMarkFont = ImageFont.truetype(font_path_helv_bold, 16)dr = ImageDraw.Draw(im)dr.rectangle(((20,0),(width-20,10)), fill=themeColor)topText = sanitzie_unicode(topText, font_path_italic)textWidth, textHeight = get_text_size(dr, topText, topFont)textPositionX = (width/2) - (textWidth/2)dr.text((textPositionX,10), topText, fill='black', font=topFont)author = sanitzie_unicode(author, font_path_italic)textWidth, textHeight = get_text_size(dr, author, authorFont)textPositionX = width - textWidth - 20textPositionY = height - textHeight - 20dr.text((textPositionX,textPositionY), author, fill='black', font=authorFont)oreillyText = "O RLY"textWidth, textHeight = get_text_size(dr, oreillyText, oriellyFont)textPositionX = 20textPositionY = height - textHeight - 20dr.text((textPositionX,textPositionY), oreillyText, fill='black', font=oriellyFont)oreillyText = "?"textPositionX = textPositionX + textWidthdr.text((textPositionX,textPositionY-1), oreillyText, fill=themeColor, font=questionMarkFont)titleFont, newTitle = clamp_title_text(sanitzie_unicode(title, font_path), width-80)if newTitle == None:raise ValueError('Title too long')textWidth, textHeight = get_text_size(dr, newTitle, titleFont, multiline=True)dr.rectangle([(20,400),(width-20,400 + textHeight + 40)], fill=themeColor)subtitle = sanitzie_unicode(guide_text, font_path_italic)if guide_text_placement == 'top_left':textWidth, textHeight = get_text_size(dr, subtitle, subtitleFont)textPositionX = 20textPositionY = 400 - textHeight - 2elif guide_text_placement == 'top_right':textWidth, textHeight = get_text_size(dr, subtitle, subtitleFont)textPositionX = width - 20 - textWidthtextPositionY = 400 - textHeight - 2elif guide_text_placement == 'bottom_left':textPositionY = 400 + textHeight + 40textWidth, textHeight = get_text_size(dr, subtitle, subtitleFont)textPositionX = 20else:#bottom_right is defaulttextPositionY = 400 + textHeight + 40textWidth, textHeight = get_text_size(dr, subtitle, subtitleFont)textPositionX = width - 20 - textWidthdr.text((textPositionX,textPositionY), subtitle, fill='black', font=subtitleFont)dr.multiline_text((40,420), newTitle, fill='white', font=titleFont)cover_image_path = os.path.abspath(os.path.join(os.path.dirname( __file__ ), '..', 'images', ('%s.png'%image_code)))coverImage = Image.open(cover_image_path).convert('RGBA')offset = (80,40)im.paste(coverImage, offset, coverImage)final_path = os.path.abspath(os.path.join(os.path.dirname( __file__ ), ('%s.png'%datetime.datetime.now())))im.save(final_path)set(cache_string, im.tobytes())im.close()return final_pathdef clamp_title_text(title, width):im = Image.new('RGBA', (500,500), "white")dr = ImageDraw.Draw(im)font_path_italic = os.path.abspath(os.path.join(os.path.dirname( __file__ ), '..', 'fonts', 'Garamond Light.ttf'))#try and fit title on one linefont = NonestartFontSize = 80endFontSize = 61for fontSize in range(startFontSize,endFontSize,-1):font = ImageFont.truetype(font_path_italic, fontSize)w, h = get_text_size(dr, title, font)if w < width:return font, title#try and fit title on two linesstartFontSize = 80endFontSize = 34for fontSize in range(startFontSize,endFontSize,-1):font = ImageFont.truetype(font_path_italic, fontSize)for match in list(re.finditer(r'\s',title, re.UNICODE)):newTitle = ''.join((title[:match.start()], '\n', title[(match.start()+1):]))substringWidth, h = get_text_size(dr, newTitle, font, multiline=True)if substringWidth < width:return font, newTitleim.close()return None, Nonedef sanitzie_unicode(string, font_file_path):sanitized_string = ''font = TTFont(font_file_path)cmap = font['cmap'].getcmap(3,1).cmapfor char in string:code_point = ord(char)if code_point in cmap.keys():sanitized_string = ''.join((sanitized_string,char))return sanitized_string
For the most up to date version, check it out on the GitHub repository.
The actual MCP connection
The MCP connection was actually the easier part of the project. I just provided a descriptive prompt describing the tool and each of it's possible parameters, and the response format (I chose to return the image directly for the quickest access in MCP clients like Claude Desktop).
That source looks like this:
"""ORLY MCP Server"""from mcp.server.fastmcp import FastMCP, Imagefrom mcp.types import TextContentimport sysimport osimport tempfileimport json# Add the parent directory to the path to import from orly_generator.modelssys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))from orly_generator.models import generate_image# Initialize FastMCP servermcp = FastMCP("ORLY")@mcp.tool(description="""Generate an O'RLY? book cover image.This tool creates a parody book cover in the style of O'Reilly books with custom title, subtitle, author, and styling options.The generated image will be displayed directly in the chat.Args:title (str): The main title for the book coversubtitle (str): The subtitle text (appears at the top)author (str): The author name (appears at the bottom right)image_code (str, optional): Image code 1-40 for the animal/object on the cover. Defaults to random.theme (str, optional): Color theme 0-16. Defaults to random.guide_text_placement (str, optional): Where to place "guide" text - 'top_left', 'top_right', 'bottom_left', 'bottom_right'. Defaults to 'bottom_right'.guide_text (str, optional): The guide text to display. Defaults to 'The Definitive Guide' As often as possible, try not to just use "The Definitive Guide" but something more creative.Returns:Image: The generated O'RLY? book cover image that will be displayed in chat.""")def generate_orly_cover(title: str,subtitle: str = "",author: str = "Anonymous",image_code: str = None,theme: str = None,guide_text_placement: str = "bottom_right",guide_text: str = "The Definitive Guide") -> Image:if not title.strip():raise ValueError("Title cannot be empty.")try:# Set defaults if not providedif image_code is None:import randomimport time# Seed the random number generator with current time to improve randomnessrandom.seed(time.time())image_code = str(random.randrange(1, 41))else:# Validate image_code is in range 1-40try:img_num = int(image_code)if not (1 <= img_num <= 40):raise ValueError(f"Image code must be between 1 and 40, got {image_code}")image_code = str(img_num)except ValueError as ve:if "Image code must be between" in str(ve):raise veraise ValueError(f"Image code must be a number between 1 and 40, got '{image_code}'")if theme is None:import randomtheme = str(random.randrange(0, 17))else:# Validate theme is in range 0-16try:theme_num = int(theme)if not (0 <= theme_num <= 16):raise ValueError(f"Theme must be between 0 and 16, got {theme}")theme = str(theme_num)except ValueError as ve:if "Theme must be between" in str(ve):raise veraise ValueError(f"Theme must be a number between 0 and 16, got '{theme}'")# Validate guide_text_placementvalid_placements = ['top_left', 'top_right', 'bottom_left', 'bottom_right']if guide_text_placement not in valid_placements:raise ValueError(f"guide_text_placement must be one of {valid_placements}, got '{guide_text_placement}'")# Generate the imageimage_path = generate_image(title=title,topText=subtitle,author=author,image_code=image_code,theme=theme,guide_text_placement=guide_text_placement,guide_text=guide_text)# Return the image using the Image helper class for direct displayreturn Image(path=image_path)except Exception as e:raise RuntimeError(f"Error generating book cover: {str(e)}") from edef main():"""Run the MCP server"""print("Starting ORLY MCP server...")mcp.run()if __name__ == "__main__":main()
Likewise, for the most up to date version, check it out on the GitHub repository.
After a few tests locally, I built the thing and published it with twine
!
The Results
Of course this is probably what you came here for. Here's a 'single-shot' sample of asking Claude to generate a few books:
Prompt:
I'd like a few books generated, each from a different area of programming - perhaps frontend, backend, embedded, devops, and so on - they should all be hilariously sarcastic and funny!
Resulting images:



