@burtthecoder/mcp-virustotal
Version:
MCP server for VirusTotal API integration
128 lines (127 loc) • 5.57 kB
JavaScript
import axios from 'axios';
import { logToFile } from './logging.js';
export class VirusTotalApiError extends Error {
constructor(message, status, code) {
super(message);
this.status = status;
this.code = code;
this.name = 'VirusTotalApiError';
}
}
let axiosInstance = null;
export function initVirusTotalClient() {
const apiKey = process.env.VIRUSTOTAL_API_KEY;
if (!apiKey) {
throw new Error('VIRUSTOTAL_API_KEY environment variable is required');
}
axiosInstance = axios.create({
baseURL: 'https://www.virustotal.com/api/v3',
headers: { 'x-apikey': apiKey },
});
}
function getClient() {
if (!axiosInstance)
initVirusTotalClient();
return axiosInstance;
}
export async function queryVirusTotal(endpoint, method = 'get', data, params) {
if (!endpoint)
throw new Error('Endpoint is required');
const client = getClient();
try {
logToFile(`${method.toUpperCase()} ${endpoint}`);
if (params)
logToFile(`Request params: ${Object.keys(params).join(', ')}`);
const response = method === 'get'
? await client.get(endpoint, { params })
: await client.post(endpoint, data, { params });
logToFile(`Response status: ${response.status}`);
return response.data;
}
catch (error) {
if (axios.isAxiosError(error)) {
const axiosError = error;
const status = axiosError.response?.status;
const code = axiosError.response?.data?.error?.code;
const message = axiosError.response?.data?.error?.message || axiosError.message;
logToFile(`API Error: ${status} - ${message}`);
throw new VirusTotalApiError(`VirusTotal API error: ${message}`, status, code);
}
throw error;
}
}
export function encodeUrlForVt(url) {
return Buffer.from(url).toString('base64url');
}
// Canonical relationship lists per VirusTotal API v3 docs
// (https://virustotal.readme.io/llms.txt — verified May 2026).
// Single source of truth: schemas/index.ts derives Zod enums from this.
export const RELATIONSHIPS = {
url: [
'analyses', 'collections', 'comments', 'communicating_files',
'contacted_domains', 'contacted_ips', 'downloaded_files',
'embedded_js_files', 'graphs', 'last_serving_ip_address',
'network_location', 'referrer_files', 'referrer_urls',
'redirecting_urls', 'redirects_to', 'related_comments',
'related_references', 'related_threat_actors', 'submissions',
'urls_related_by_tracker_id', 'user_votes', 'votes',
],
file: [
'analyses', 'behaviours', 'bundled_files', 'carbonblack_children',
'carbonblack_parents', 'ciphered_bundled_files', 'ciphered_parents',
'collections', 'comments', 'compressed_parents', 'contacted_domains',
'contacted_ips', 'contacted_urls', 'dropped_files', 'email_attachments',
'email_parents', 'embedded_domains', 'embedded_ips', 'embedded_urls',
'execution_parents', 'graphs', 'itw_domains', 'itw_ips', 'itw_urls',
'memory_pattern_domains', 'memory_pattern_ips', 'memory_pattern_urls',
'overlay_children', 'overlay_parents', 'pcap_children', 'pcap_parents',
'pe_resource_children', 'pe_resource_parents', 'related_references',
'related_threat_actors', 'similar_files', 'submissions', 'screenshots',
'urls_for_embedded_js', 'votes',
],
ip: [
'collections', 'comments', 'communicating_files', 'downloaded_files',
'graphs', 'historical_ssl_certificates', 'historical_whois',
'related_comments', 'related_references', 'related_threat_actors',
'referrer_files', 'resolutions', 'urls', 'user_votes', 'votes',
],
domain: [
'caa_records', 'cname_records', 'collections', 'comments',
'communicating_files', 'downloaded_files', 'graphs',
'historical_ssl_certificates', 'historical_whois', 'immediate_parent',
'mx_records', 'ns_records', 'parent', 'referrer_files',
'related_comments', 'related_references', 'related_threat_actors',
'resolutions', 'soa_records', 'siblings', 'subdomains', 'urls',
'user_votes', 'votes',
],
collection: [
'autogenerated_graphs', 'comments', 'domains', 'files', 'ip_addresses',
'owner', 'references', 'related_collections', 'related_references',
'threat_actors', 'urls',
],
};
// VT caps the relationships you can request in a single call; batch to be safe.
const RELATIONSHIPS_PER_REQUEST = 8;
// Fetch an object + a set of relationship summaries in as few calls as
// possible by using VT's `?relationships=a,b,c` query, batched.
export async function queryVirusTotalWithRelationships(baseEndpoint, relationships) {
if (relationships.length === 0) {
return queryVirusTotal(baseEndpoint);
}
const batches = [];
for (let i = 0; i < relationships.length; i += RELATIONSHIPS_PER_REQUEST) {
batches.push([...relationships.slice(i, i + RELATIONSHIPS_PER_REQUEST)]);
}
const responses = await Promise.all(batches.map((batch) => queryVirusTotal(baseEndpoint, 'get', undefined, {
relationships: batch.join(','),
})));
const base = responses[0];
const mergedRelationships = responses.reduce((acc, r) => ({ ...acc, ...(r?.data?.relationships || {}) }), {});
return {
...base,
data: {
...base.data,
relationships: mergedRelationships,
},
};
}