@codewithdan/dc-comics-mcp
Version:
DC Comics APIs MCP Server using Comic Vine API
320 lines (319 loc) • 11.7 kB
JavaScript
import { config } from 'dotenv';
import { ResourcePrefix, SearchResponseSchema } from './tools/schemas.js';
config();
const COMIC_VINE_API_KEY = process.env.COMIC_VINE_API_KEY;
const COMIC_VINE_API_BASE = process.env.COMIC_VINE_API_BASE;
if (!COMIC_VINE_API_KEY)
throw new Error('Missing COMIC_VINE_API_KEY env variable');
if (!COMIC_VINE_API_BASE)
throw new Error('Missing COMIC_VINE_API_BASE env variable');
// Define default field lists for different resource types
export const DEFAULT_FIELD_LISTS = {
CHARACTER: 'aliases,character_enemies,character_friends,id,image,movies,name,powers,real_name,team_enemies,team_friends',
ISSUE: 'id,name,image,issue_number,cover_date,description,character_credits',
MOVIE: 'id,name,deck,description,image,release_date,runtime,rating,box_office_revenue,total_revenue,budget,studios,writers,producers'
};
// Helper function to create standardized API responses
export function createStandardResponse(responseSchema, dataOrError, defaultLimit = 20) {
return responseSchema.parse({
status_code: dataOrError.status_code || 1,
error: dataOrError.error || 'OK',
number_of_total_results: dataOrError.number_of_total_results || (Array.isArray(dataOrError.results) ? dataOrError.results.length : 1),
number_of_page_results: dataOrError.number_of_page_results || (Array.isArray(dataOrError.results) ? dataOrError.results.length : 1),
limit: dataOrError.limit || defaultLimit,
offset: dataOrError.offset || 0,
results: dataOrError.results || []
});
}
// Helper function to create an empty results response
export function createEmptyResponse(responseSchema, limit = 20, offset = 0) {
return responseSchema.parse({
status_code: 1,
error: "OK",
number_of_total_results: 0,
number_of_page_results: 0,
limit: limit,
offset: offset,
results: []
});
}
/**
* Helper function for fetching resources by ID with proper formatting
* @param resourceType Type of resource (e.g., 'CHARACTER', 'ISSUE', 'MOVIE')
* @param id Numeric ID of the resource
* @param fieldList Optional field list to include in response
* @param defaultFields Default fields to use if fieldList not provided
* @returns Promise with the API response
*/
export async function getResourceById(resourceType, id, fieldList, defaultFields) {
// Format resource ID with the proper prefix
const formattedId = formatResourceId(resourceType, id);
// Set up parameters with default field list if not provided
const params = {
field_list: fieldList || (defaultFields || DEFAULT_FIELD_LISTS[resourceType] || '')
};
// Make the API request
const resourcePath = resourceType.toLowerCase();
return await httpRequest(`/${resourcePath}/${formattedId}`, params);
}
/**
* Helper function to standardize parameter handling for list requests
* @param endpoint API endpoint to request
* @param args Request parameters
* @param defaultResourceType Resource type for default field list
* @returns Promise with the API response
*/
export async function getResourcesList(endpoint, args, defaultResourceType) {
// Ensure field_list is set with defaults if not provided
if (defaultResourceType && !args.field_list) {
args.field_list = DEFAULT_FIELD_LISTS[defaultResourceType];
}
// Make the API request
return await httpRequest(endpoint, serializeQueryParams(args));
}
// API utility functions that can be used by multiple tools
// Reusable function for performing searches across DC Comics resources
export async function performDcComicsSearch(query, resources, field_list, limit, offset) {
// Create params object with all available search parameters
const params = serializeQueryParams({
query,
resources,
field_list,
limit,
offset
});
// Make the API request to the search endpoint
const res = await httpRequest('/search', params);
// Process the results to ensure null names are handled correctly
if (res.results && Array.isArray(res.results)) {
res.results = res.results.map((item) => {
// Ensure name is an empty string if it's null
if (item.name === null) {
item.name = '';
}
return item;
});
}
// Validate the response with the SearchResponseSchema
return SearchResponseSchema.parse(res);
}
// Helper function to format resource IDs with their proper prefix
export function formatResourceId(resourceType, id) {
const prefix = ResourcePrefix[resourceType];
return `${prefix}-${id}`;
}
export function serializeQueryParams(params) {
const result = {};
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
result[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return result;
}
export async function httpRequest(endpoint, params = {}) {
const url = new URL(`${COMIC_VINE_API_BASE}${endpoint}`);
// Set format to json and add API key
url.searchParams.set('format', 'json');
url.searchParams.set('api_key', COMIC_VINE_API_KEY);
// Add other parameters
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
const res = await fetch(url.toString());
if (!res.ok) {
const text = await res.text();
throw new Error(`Comic Vine API error: ${res.status} - ${text}`);
}
return res.json();
}
/**
* Generates an HTML page displaying comic issues with their cover images
*
* @param issues Array of issue objects from the Comic Vine API
* @param title Title for the HTML page
* @returns HTML string
*/
export function generateComicsHtml(issues, title) {
let html = `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f5f5;
padding: 20px;
margin: 0;
}
.header {
background-color: #0282f9; /* DC Comics blue */
color: white;
padding: 20px;
margin-bottom: 20px;
text-align: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
h1 {
margin: 0;
}
.subheader {
text-align: center;
color: #666;
margin-bottom: 20px;
}
.comics-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
max-width: 1400px;
margin: 0 auto;
}
.comic-card {
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
overflow: hidden;
width: 300px;
transition: transform 0.3s, box-shadow 0.3s;
}
.comic-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
}
.comic-image-container {
height: 450px;
overflow: hidden;
position: relative;
background-color: #f0f0f0;
}
.comic-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.comic-card:hover .comic-image {
transform: scale(1.05);
}
.comic-info {
padding: 15px;
}
.comic-title {
font-weight: bold;
margin-bottom: 5px;
font-size: 1.1em;
}
.comic-issue {
color: #666;
font-size: 0.9em;
margin-bottom: 8px;
}
.comic-description {
font-size: 0.85em;
color: #444;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
margin-top: 8px;
}
.comic-date {
font-size: 0.8em;
color: #777;
margin-top: 8px;
}
.footer {
text-align: center;
margin-top: 30px;
padding: 20px;
color: #666;
font-size: 0.8em;
}
.empty-state {
text-align: center;
padding: 50px;
color: #666;
}
</style>
</head>
<body>
<div class="header">
<h1>${escapeHtml(title)}</h1>
</div>
<div class="subheader">
<p>Showing ${issues.length} issues</p>
</div>
<div class="comics-container">
`;
if (issues.length === 0) {
html += `
<div class="empty-state">
<h2>No issues found</h2>
<p>Try adjusting your search parameters</p>
</div>
`;
}
else {
issues.forEach(issue => {
// Get the best available image URL
const imgUrl = issue.image ? (issue.image.super_url || issue.image.screen_large_url || issue.image.medium_url) : '';
const title = issue.name || issue.volume?.name || 'Unknown Title';
const issueNumber = issue.issue_number || 'N/A';
// Format date if available
let dateStr = '';
if (issue.cover_date) {
const date = new Date(issue.cover_date);
if (!isNaN(date.getTime())) {
dateStr = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}
// Get short description if available
const description = issue.deck || issue.description || '';
const cleanDescription = description.replace(/<\/?[^>]+(>|$)/g, ""); // Strip HTML tags
html += `
<div class="comic-card">
<div class="comic-image-container">
<img class="comic-image" src="${imgUrl}" alt="${escapeHtml(title)}" onerror="this.src='https://comicvine.gamespot.com/a/uploads/original/0/40/1017179-noimage.png';">
</div>
<div class="comic-info">
<div class="comic-title">${escapeHtml(title)}</div>
<div class="comic-issue">Issue ${issueNumber}</div>
${dateStr ? `<div class="comic-date">Cover Date: ${dateStr}</div>` : ''}
${cleanDescription ? `<div class="comic-description">${escapeHtml(cleanDescription.substring(0, 150))}${cleanDescription.length > 150 ? '...' : ''}</div>` : ''}
</div>
</div>
`;
});
}
html += `
</div>
<div class="footer">
<p>Data provided by Comic Vine. © ${new Date().getFullYear()} DC Comics</p>
</div>
</body>
</html>
`;
return html;
}
/**
* Helper function to escape HTML special characters
*/
function escapeHtml(text) {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}