import { spawn } from 'child_process';
import { EventEmitter } from 'events';
/**
* Real MCP protocol implementation for extracting tool schemas
*/
export class MCPSchemaExtractor extends EventEmitter {
constructor() {
super();
this.timeout = parseInt(process.env.MCP_TIMEOUT) || 10000;
this.requestId = 0;
}
/**
* Extract schemas from a single MCP server using real protocol
* @param {Object} serverConfig Server configuration
* @returns {Promise<Object>} Server schema data
*/
async extractServerSchema(serverConfig) {
const result = {
serverName: serverConfig.name,
success: false,
tools: [],
error: null,
metadata: {
connectionTime: 0,
toolCount: 0,
totalSchemaSize: 0,
protocolVersion: null
},
// New investigation fields
investigation: {
rawResponseSize: 0,
rawResponse: null,
initializeResponseSize: 0,
toolsWithOutputSchemas: 0,
additionalFieldsFound: [],
jsonFormattingTests: {}
}
};
const startTime = Date.now();
try {
// Start the MCP server process
const serverProcess = await this.startServer(serverConfig);
result.metadata.connectionTime = Date.now() - startTime;
// Perform MCP handshake
const handshakeResult = await this.performHandshake(serverProcess, serverConfig);
result.investigation.initializeResponseSize = JSON.stringify(handshakeResult).length;
// Get ALL MCP protocol data that Claude might include in context
const toolsResult = await this.getToolsFromServer(serverProcess, serverConfig);
const resourcesResult = await this.getResourcesFromServer(serverProcess, serverConfig);
const promptsResult = await this.getPromptsFromServer(serverProcess, serverConfig);
result.tools = toolsResult.tools;
result.investigation.rawResponse = toolsResult.rawResponse;
result.investigation.rawResponseSize = toolsResult.rawResponseSize;
result.investigation.toolsWithOutputSchemas = toolsResult.toolsWithOutputSchemas;
result.investigation.additionalFieldsFound = toolsResult.additionalFieldsFound;
result.investigation.jsonFormattingTests = toolsResult.jsonFormattingTests;
// NEW: Additional protocol data
result.investigation.resourcesData = resourcesResult;
result.investigation.promptsData = promptsResult;
result.metadata.toolCount = toolsResult.tools.length;
result.metadata.totalSchemaSize = this.calculateSchemaSize(toolsResult.tools);
result.success = true;
// Clean up
await this.stopServer(serverProcess);
} catch (error) {
result.error = error.message;
this.emit('error', `Failed to extract schema from ${serverConfig.name}: ${error.message}`);
}
return result;
}
/**
* Start an MCP server process
* @param {Object} serverConfig Server configuration
* @returns {Promise<Object>} Server process information
*/
async startServer(serverConfig) {
return new Promise((resolve, reject) => {
const command = serverConfig.command;
const args = serverConfig.args || [];
const env = { ...process.env, ...serverConfig.env };
const cwd = serverConfig.cwd || process.cwd();
this.emit('info', `Starting server: ${command} ${args.join(' ')}`);
const child = spawn(command, args, {
env,
cwd,
stdio: ['pipe', 'pipe', 'pipe']
});
const timeout = setTimeout(() => {
child.kill();
reject(new Error(`Server startup timeout after ${this.timeout}ms`));
}, this.timeout);
child.on('error', (error) => {
clearTimeout(timeout);
reject(new Error(`Failed to start server: ${error.message}`));
});
// Give server time to start and set up stdio
setTimeout(() => {
clearTimeout(timeout);
resolve({
process: child,
pid: child.pid,
command: command,
args: args,
stdin: child.stdin,
stdout: child.stdout,
stderr: child.stderr
});
}, 2000);
});
}
/**
* Perform MCP protocol handshake
* @param {Object} serverProcess Server process information
* @param {Object} serverConfig Server configuration
* @returns {Promise<Object>} Handshake result
*/
async performHandshake(serverProcess, serverConfig) {
// Step 1: Send initialize request
const initializeRequest = {
jsonrpc: "2.0",
id: ++this.requestId,
method: "initialize",
params: {
protocolVersion: "2025-06-18",
capabilities: {
roots: { listChanged: true },
sampling: {}
},
clientInfo: {
name: "MCP-Token-Analyzer",
version: "1.0.0"
}
}
};
const initResponse = await this.sendRequest(serverProcess, initializeRequest);
if (initResponse.error) {
throw new Error(`Initialize failed: ${initResponse.error.message}`);
}
// Step 2: Send initialized notification
const initializedNotification = {
jsonrpc: "2.0",
method: "notifications/initialized"
};
await this.sendNotification(serverProcess, initializedNotification);
return initResponse.result;
}
/**
* Get tools from MCP server using tools/list request with investigation data
* @param {Object} serverProcess Server process information
* @param {Object} serverConfig Server configuration
* @returns {Promise<Object>} Detailed tool analysis result
*/
async getToolsFromServer(serverProcess, serverConfig) {
const toolsListRequest = {
jsonrpc: "2.0",
id: ++this.requestId,
method: "tools/list",
params: {}
};
const response = await this.sendRequest(serverProcess, toolsListRequest);
if (response.error) {
throw new Error(`Tools list failed: ${response.error.message}`);
}
const tools = response.result.tools || [];
// Investigation analysis
const rawResponseSize = JSON.stringify(response).length;
const rawResponsePretty = JSON.stringify(response, null, 2).length;
const rawResponseMinified = JSON.stringify(response).length;
let toolsWithOutputSchemas = 0;
const additionalFieldsFound = new Set();
// Analyze each tool for missing content
tools.forEach(tool => {
// Check for output schema
if (tool.outputSchema) {
toolsWithOutputSchemas++;
}
// Catalog all fields beyond the basic ones we currently count
const basicFields = new Set(['name', 'title', 'description', 'inputSchema', 'annotations']);
Object.keys(tool).forEach(field => {
if (!basicFields.has(field)) {
additionalFieldsFound.add(field);
}
});
});
return {
tools,
rawResponse: response,
rawResponseSize: rawResponsePretty, // Use pretty-printed size as baseline
toolsWithOutputSchemas,
additionalFieldsFound: Array.from(additionalFieldsFound),
jsonFormattingTests: {
prettyPrinted: rawResponsePretty,
minified: rawResponseMinified,
difference: rawResponsePretty - rawResponseMinified
}
};
}
/**
* Get resources from MCP server using resources/list request
* @param {Object} serverProcess Server process information
* @param {Object} serverConfig Server configuration
* @returns {Promise<Object>} Resources analysis result
*/
async getResourcesFromServer(serverProcess, serverConfig) {
const resourcesListRequest = {
jsonrpc: "2.0",
id: ++this.requestId,
method: "resources/list",
params: {}
};
try {
const response = await this.sendRequest(serverProcess, resourcesListRequest);
if (response.error) {
return {
success: false,
error: response.error.message,
resources: [],
tokenCount: 0
};
}
const resources = response.result.resources || [];
const responseSize = JSON.stringify(response, null, 2).length;
return {
success: true,
resources: resources,
responseSize: responseSize,
tokenCount: Math.ceil(responseSize / 3.5),
resourceCount: resources.length
};
} catch (error) {
return {
success: false,
error: error.message,
resources: [],
tokenCount: 0
};
}
}
/**
* Get prompts from MCP server using prompts/list request
* @param {Object} serverProcess Server process information
* @param {Object} serverConfig Server configuration
* @returns {Promise<Object>} Prompts analysis result
*/
async getPromptsFromServer(serverProcess, serverConfig) {
const promptsListRequest = {
jsonrpc: "2.0",
id: ++this.requestId,
method: "prompts/list",
params: {}
};
try {
const response = await this.sendRequest(serverProcess, promptsListRequest);
if (response.error) {
return {
success: false,
error: response.error.message,
prompts: [],
tokenCount: 0
};
}
const prompts = response.result.prompts || [];
const responseSize = JSON.stringify(response, null, 2).length;
return {
success: true,
prompts: prompts,
responseSize: responseSize,
tokenCount: Math.ceil(responseSize / 3.5),
promptCount: prompts.length
};
} catch (error) {
return {
success: false,
error: error.message,
prompts: [],
tokenCount: 0
};
}
}
/**
* Send JSON-RPC request and wait for response
* @param {Object} serverProcess Server process information
* @param {Object} request JSON-RPC request
* @returns {Promise<Object>} Response object
*/
async sendRequest(serverProcess, request) {
return new Promise((resolve, reject) => {
const requestStr = JSON.stringify(request) + '\n';
let responseBuffer = '';
let responseReceived = false;
const responseHandler = (data) => {
responseBuffer += data.toString();
// Look for complete JSON-RPC response
const lines = responseBuffer.split('\n');
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
if (line) {
try {
const response = JSON.parse(line);
if (response.id === request.id && !responseReceived) {
responseReceived = true;
serverProcess.stdout.removeListener('data', responseHandler);
resolve(response);
return;
}
} catch (parseError) {
// Continue looking for valid JSON
}
}
}
};
const errorHandler = (data) => {
const errorStr = data.toString();
// Don't treat informational/startup messages as errors
if (errorStr.includes('running on stdio') ||
errorStr.includes('Server starting') ||
errorStr.includes('Listening') ||
errorStr.includes('Initializing') ||
errorStr.includes('Started') ||
errorStr.includes('Ready') ||
errorStr.toLowerCase().includes('info:') ||
errorStr.toLowerCase().includes('debug:') ||
errorStr.toLowerCase().includes('warning:')) {
// Log but don't fail on informational messages
this.emit('info', `Server info: ${errorStr.trim()}`);
return;
}
// Only fail on actual errors like "Error:", "Failed:", "Exception:"
if (errorStr.toLowerCase().includes('error:') ||
errorStr.toLowerCase().includes('failed:') ||
errorStr.toLowerCase().includes('exception:') ||
errorStr.toLowerCase().includes('fatal:')) {
if (!responseReceived) {
responseReceived = true;
serverProcess.stdout.removeListener('data', responseHandler);
serverProcess.stderr.removeListener('data', errorHandler);
reject(new Error(`Server error: ${errorStr}`));
}
} else {
// Log other stderr output but don't fail
this.emit('info', `Server message: ${errorStr.trim()}`);
}
};
// Set up response handlers
serverProcess.stdout.on('data', responseHandler);
serverProcess.stderr.on('data', errorHandler);
// Send request
try {
serverProcess.stdin.write(requestStr);
} catch (error) {
responseReceived = true;
serverProcess.stdout.removeListener('data', responseHandler);
serverProcess.stderr.removeListener('data', errorHandler);
reject(new Error(`Failed to send request: ${error.message}`));
}
// Set timeout
setTimeout(() => {
if (!responseReceived) {
responseReceived = true;
serverProcess.stdout.removeListener('data', responseHandler);
serverProcess.stderr.removeListener('data', errorHandler);
reject(new Error(`Request timeout after ${this.timeout}ms`));
}
}, this.timeout);
});
}
/**
* Send JSON-RPC notification (no response expected)
* @param {Object} serverProcess Server process information
* @param {Object} notification JSON-RPC notification
*/
async sendNotification(serverProcess, notification) {
const notificationStr = JSON.stringify(notification) + '\n';
try {
serverProcess.stdin.write(notificationStr);
// Small delay to ensure notification is processed
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
throw new Error(`Failed to send notification: ${error.message}`);
}
}
/**
* Calculate the approximate size of tool schemas in characters
* @param {Array} tools Array of tool definitions
* @returns {number} Total schema size in characters
*/
calculateSchemaSize(tools) {
return tools.reduce((total, tool) => {
const toolJson = JSON.stringify(tool, null, 2);
return total + toolJson.length;
}, 0);
}
/**
* Stop an MCP server process
* @param {Object} serverProcess Server process information
* @returns {Promise<void>}
*/
async stopServer(serverProcess) {
return new Promise((resolve) => {
if (serverProcess.process && !serverProcess.process.killed) {
// Close stdin first to signal shutdown
try {
serverProcess.stdin.end();
} catch (error) {
// Ignore errors when closing stdin
}
// Give server time to shutdown gracefully
setTimeout(() => {
if (!serverProcess.process.killed) {
serverProcess.process.kill('SIGTERM');
// Force kill if it doesn't respond
setTimeout(() => {
if (!serverProcess.process.killed) {
serverProcess.process.kill('SIGKILL');
}
resolve();
}, 2000);
} else {
resolve();
}
}, 1000);
} else {
resolve();
}
});
}
/**
* Extract schemas from multiple servers
* @param {Array} serverConfigs Array of server configurations
* @returns {Promise<Array>} Array of server schema results
*/
async extractMultipleServerSchemas(serverConfigs) {
const results = [];
for (const serverConfig of serverConfigs) {
this.emit('progress', `Extracting schema from ${serverConfig.name}...`);
try {
const result = await this.extractServerSchema(serverConfig);
results.push(result);
if (result.success) {
this.emit('info', `✅ ${serverConfig.name}: ${result.metadata.toolCount} tools, ${result.metadata.totalSchemaSize} chars`);
} else {
this.emit('info', `⚠️ ${serverConfig.name}: ${result.error}`);
}
} catch (error) {
// If extraction completely fails, create a failed result
const failedResult = {
serverName: serverConfig.name,
success: false,
tools: [],
error: error.message,
metadata: {
connectionTime: 0,
toolCount: 0,
totalSchemaSize: 0,
protocolVersion: null
}
};
results.push(failedResult);
this.emit('info', `❌ ${serverConfig.name}: ${error.message}`);
}
}
return results;
}
/**
* Get summary statistics from extraction results
* @param {Array} extractionResults Array of extraction results
* @returns {Object} Summary statistics
*/
getSummaryStatistics(extractionResults) {
const successful = extractionResults.filter(r => r.success);
const failed = extractionResults.filter(r => !r.success);
const totalTools = successful.reduce((sum, r) => sum + r.metadata.toolCount, 0);
const totalSchemaSize = successful.reduce((sum, r) => sum + r.metadata.totalSchemaSize, 0);
const avgConnectionTime = successful.length > 0
? successful.reduce((sum, r) => sum + r.metadata.connectionTime, 0) / successful.length
: 0;
return {
totalServers: extractionResults.length,
successfulServers: successful.length,
failedServers: failed.length,
totalTools,
totalSchemaSize,
averageConnectionTime: Math.round(avgConnectionTime),
averageToolsPerServer: successful.length > 0 ? Math.round(totalTools / successful.length) : 0,
failedServersList: failed.map(f => ({ name: f.serverName, error: f.error }))
};
}
}