UNPKG

mcp-xml

Version:

MCP server for XML file operations - read, analyze, search, find, relate, and write XML files

744 lines 31.9 kB
#!/usr/bin/env node "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js"); const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js"); const types_js_1 = require("@modelcontextprotocol/sdk/types.js"); const fast_xml_parser_1 = require("fast-xml-parser"); const glob_1 = require("glob"); const fs_extra_1 = __importDefault(require("fs-extra")); const path_1 = __importDefault(require("path")); class XMLMCPServer { constructor() { this.server = new index_js_1.Server({ name: 'mcp-xml', version: '1.0.0', }, { capabilities: { tools: {}, }, }); this.parser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', allowBooleanAttributes: true, parseTagValue: false, trimValues: true, }); this.builder = new fast_xml_parser_1.XMLBuilder({ ignoreAttributes: false, attributeNamePrefix: '@_', format: true, indentBy: ' ', }); this.workingDirectory = process.cwd(); this.setupToolHandlers(); } setupToolHandlers() { this.server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools: [ { name: 'find_xml_files', description: 'Find XML files in the current directory and subdirectories', inputSchema: { type: 'object', properties: { directory: { type: 'string', description: 'Directory to search (defaults to current directory)', }, pattern: { type: 'string', description: 'Glob pattern for XML files (defaults to **/*.xml)', }, }, }, }, { name: 'read_xml_file', description: 'Read and parse an XML file', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the XML file to read', }, }, required: ['filePath'], }, }, { name: 'analyze_xml_file', description: 'Analyze an XML file structure and content', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the XML file to analyze', }, }, required: ['filePath'], }, }, { name: 'search_xml_content', description: 'Search for specific content within XML files', inputSchema: { type: 'object', properties: { searchTerm: { type: 'string', description: 'Term to search for in XML content', }, directory: { type: 'string', description: 'Directory to search (defaults to current directory)', }, elementName: { type: 'string', description: 'Specific XML element name to search within', }, }, required: ['searchTerm'], }, }, { name: 'write_xml_file', description: 'Write data to an XML file', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path where to write the XML file', }, data: { type: 'object', description: 'JavaScript object to convert to XML', }, xmlDeclaration: { type: 'boolean', description: 'Include XML declaration (defaults to true)', }, }, required: ['filePath', 'data'], }, }, { name: 'transform_xml', description: 'Transform XML content using a transformation function', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the XML file to transform', }, outputPath: { type: 'string', description: 'Path where to save the transformed XML', }, operation: { type: 'string', enum: ['filter_elements', 'rename_elements', 'add_attributes', 'remove_attributes'], description: 'Type of transformation to perform', }, parameters: { type: 'object', description: 'Parameters for the transformation operation', }, }, required: ['filePath', 'outputPath', 'operation'], }, }, { name: 'relate_xml_files', description: 'Find relationships between XML files based on content or structure', inputSchema: { type: 'object', properties: { directory: { type: 'string', description: 'Directory to analyze (defaults to current directory)', }, relationType: { type: 'string', enum: ['common_elements', 'shared_attributes', 'similar_structure', 'reference_links'], description: 'Type of relationship to analyze', }, }, required: ['relationType'], }, }, { name: 'validate_xml', description: 'Validate XML file structure and well-formedness', inputSchema: { type: 'object', properties: { filePath: { type: 'string', description: 'Path to the XML file to validate', }, }, required: ['filePath'], }, }, ], })); this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { switch (name) { case 'find_xml_files': return await this.findXMLFiles(args); case 'read_xml_file': return await this.readXMLFile(args); case 'analyze_xml_file': return await this.analyzeXMLFile(args); case 'search_xml_content': return await this.searchXMLContent(args); case 'write_xml_file': return await this.writeXMLFile(args); case 'transform_xml': return await this.transformXML(args); case 'relate_xml_files': return await this.relateXMLFiles(args); case 'validate_xml': return await this.validateXML(args); default: throw new types_js_1.McpError(types_js_1.ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } catch (error) { throw new types_js_1.McpError(types_js_1.ErrorCode.InternalError, `Error executing ${name}: ${error instanceof Error ? error.message : String(error)}`); } }); } async findXMLFiles(args) { const directory = args.directory || this.workingDirectory; const pattern = args.pattern || '**/*.xml'; const searchPattern = path_1.default.join(directory, pattern); const files = await (0, glob_1.glob)(searchPattern, { ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**'] }); return { content: [ { type: 'text', text: `Found ${files.length} XML files:\n${files.map(f => `- ${path_1.default.relative(this.workingDirectory, f)}`).join('\n')}`, }, ], }; } async readXMLFile(args) { const filePath = path_1.default.resolve(this.workingDirectory, args.filePath); if (!await fs_extra_1.default.pathExists(filePath)) { throw new Error(`File not found: ${filePath}`); } const content = await fs_extra_1.default.readFile(filePath, 'utf8'); const parsed = this.parser.parse(content); return { content: [ { type: 'text', text: `XML Content from ${args.filePath}:\n\nRaw XML:\n${content}\n\nParsed Structure:\n${JSON.stringify(parsed, null, 2)}`, }, ], }; } async analyzeXMLFile(args) { const filePath = path_1.default.resolve(this.workingDirectory, args.filePath); if (!await fs_extra_1.default.pathExists(filePath)) { throw new Error(`File not found: ${filePath}`); } const content = await fs_extra_1.default.readFile(filePath, 'utf8'); const parsed = this.parser.parse(content); const analysis = this.analyzeXMLStructure(parsed, content); return { content: [ { type: 'text', text: `XML Analysis for ${args.filePath}:\n\n` + `Element Count: ${analysis.elementCount}\n` + `Attribute Count: ${analysis.attributeCount}\n` + `Max Depth: ${analysis.depth}\n` + `Has Namespaces: ${analysis.hasNamespaces}\n` + `Elements Found: ${analysis.elements.join(', ')}\n` + `Attributes Found: ${analysis.attributes.join(', ')}\n` + `Namespaces: ${analysis.namespaces.join(', ')}\n`, }, ], }; } analyzeXMLStructure(obj, rawContent) { const elements = new Set(); const attributes = new Set(); const namespaces = new Set(); let maxDepth = 0; const traverse = (node, depth = 0) => { maxDepth = Math.max(maxDepth, depth); if (typeof node === 'object' && node !== null) { Object.keys(node).forEach(key => { if (key.startsWith('@_')) { attributes.add(key.substring(2)); } else if (key.includes(':')) { namespaces.add(key.split(':')[0]); elements.add(key); } else if (key !== '?xml') { elements.add(key); } if (Array.isArray(node[key])) { node[key].forEach((item) => traverse(item, depth + 1)); } else if (typeof node[key] === 'object') { traverse(node[key], depth + 1); } }); } }; traverse(obj); return { elementCount: elements.size, elements: Array.from(elements), attributeCount: attributes.size, attributes: Array.from(attributes), depth: maxDepth, hasNamespaces: namespaces.size > 0, namespaces: Array.from(namespaces), }; } async searchXMLContent(args) { const directory = args.directory || this.workingDirectory; const searchTerm = args.searchTerm; const elementName = args.elementName; const xmlFiles = await (0, glob_1.glob)(path_1.default.join(directory, '**/*.xml'), { ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**'] }); const results = []; for (const file of xmlFiles) { try { const content = await fs_extra_1.default.readFile(file, 'utf8'); const matches = []; if (elementName) { const parsed = this.parser.parse(content); const elementMatches = this.findInElement(parsed, elementName, searchTerm); matches.push(...elementMatches); } else { if (content.includes(searchTerm)) { const lines = content.split('\n'); lines.forEach((line, index) => { if (line.includes(searchTerm)) { matches.push(`Line ${index + 1}: ${line.trim()}`); } }); } } if (matches.length > 0) { results.push({ file: path_1.default.relative(this.workingDirectory, file), matches, }); } } catch (error) { // Skip files that can't be read or parsed } } return { content: [ { type: 'text', text: `Search Results for "${searchTerm}"${elementName ? ` in element "${elementName}"` : ''}:\n\n` + results.map(result => `${result.file}:\n${result.matches.map(match => ` - ${match}`).join('\n')}`).join('\n\n') || 'No matches found.', }, ], }; } findInElement(obj, elementName, searchTerm) { const matches = []; const search = (node, path = '') => { if (typeof node === 'object' && node !== null) { Object.keys(node).forEach(key => { const currentPath = path ? `${path}.${key}` : key; if (key === elementName || key.endsWith(`:${elementName}`)) { const value = typeof node[key] === 'string' ? node[key] : JSON.stringify(node[key]); if (value.includes(searchTerm)) { matches.push(`Found in ${currentPath}: ${value}`); } } if (Array.isArray(node[key])) { node[key].forEach((item, index) => { search(item, `${currentPath}[${index}]`); }); } else if (typeof node[key] === 'object') { search(node[key], currentPath); } }); } }; search(obj); return matches; } async writeXMLFile(args) { const filePath = path_1.default.resolve(this.workingDirectory, args.filePath); const data = args.data; const includeDeclaration = args.xmlDeclaration !== false; await fs_extra_1.default.ensureDir(path_1.default.dirname(filePath)); let xmlContent = this.builder.build(data); if (includeDeclaration && !xmlContent.startsWith('<?xml')) { xmlContent = '<?xml version="1.0" encoding="UTF-8"?>\n' + xmlContent; } await fs_extra_1.default.writeFile(filePath, xmlContent, 'utf8'); return { content: [ { type: 'text', text: `Successfully wrote XML file to ${args.filePath}\n\nContent:\n${xmlContent}`, }, ], }; } async transformXML(args) { const filePath = path_1.default.resolve(this.workingDirectory, args.filePath); const outputPath = path_1.default.resolve(this.workingDirectory, args.outputPath); const operation = args.operation; const parameters = args.parameters || {}; if (!await fs_extra_1.default.pathExists(filePath)) { throw new Error(`File not found: ${filePath}`); } const content = await fs_extra_1.default.readFile(filePath, 'utf8'); const parsed = this.parser.parse(content); let transformed; switch (operation) { case 'filter_elements': transformed = this.filterElements(parsed, parameters.elements || []); break; case 'rename_elements': transformed = this.renameElements(parsed, parameters.mapping || {}); break; case 'add_attributes': transformed = this.addAttributes(parsed, parameters.element, parameters.attributes || {}); break; case 'remove_attributes': transformed = this.removeAttributes(parsed, parameters.element, parameters.attributes || []); break; default: throw new Error(`Unknown transformation operation: ${operation}`); } await fs_extra_1.default.ensureDir(path_1.default.dirname(outputPath)); const xmlContent = '<?xml version="1.0" encoding="UTF-8"?>\n' + this.builder.build(transformed); await fs_extra_1.default.writeFile(outputPath, xmlContent, 'utf8'); return { content: [ { type: 'text', text: `Successfully transformed XML from ${args.filePath} to ${args.outputPath}\n\nTransformation: ${operation}\nParameters: ${JSON.stringify(parameters, null, 2)}`, }, ], }; } filterElements(obj, elementsToKeep) { if (typeof obj !== 'object' || obj === null) return obj; const filtered = {}; Object.keys(obj).forEach(key => { if (elementsToKeep.includes(key) || key.startsWith('@_') || key === '?xml') { if (Array.isArray(obj[key])) { filtered[key] = obj[key].map((item) => this.filterElements(item, elementsToKeep)); } else if (typeof obj[key] === 'object') { filtered[key] = this.filterElements(obj[key], elementsToKeep); } else { filtered[key] = obj[key]; } } }); return filtered; } renameElements(obj, mapping) { if (typeof obj !== 'object' || obj === null) return obj; const renamed = {}; Object.keys(obj).forEach(key => { const newKey = mapping[key] || key; if (Array.isArray(obj[key])) { renamed[newKey] = obj[key].map((item) => this.renameElements(item, mapping)); } else if (typeof obj[key] === 'object') { renamed[newKey] = this.renameElements(obj[key], mapping); } else { renamed[newKey] = obj[key]; } }); return renamed; } addAttributes(obj, elementName, attributes) { if (typeof obj !== 'object' || obj === null) return obj; const modified = { ...obj }; Object.keys(modified).forEach(key => { if (key === elementName) { Object.keys(attributes).forEach(attr => { modified[`@_${attr}`] = attributes[attr]; }); } if (Array.isArray(modified[key])) { modified[key] = modified[key].map((item) => this.addAttributes(item, elementName, attributes)); } else if (typeof modified[key] === 'object') { modified[key] = this.addAttributes(modified[key], elementName, attributes); } }); return modified; } removeAttributes(obj, elementName, attributes) { if (typeof obj !== 'object' || obj === null) return obj; const modified = { ...obj }; Object.keys(modified).forEach(key => { if (key === elementName) { attributes.forEach(attr => { delete modified[`@_${attr}`]; }); } if (Array.isArray(modified[key])) { modified[key] = modified[key].map((item) => this.removeAttributes(item, elementName, attributes)); } else if (typeof modified[key] === 'object') { modified[key] = this.removeAttributes(modified[key], elementName, attributes); } }); return modified; } async relateXMLFiles(args) { const directory = args.directory || this.workingDirectory; const relationType = args.relationType; const xmlFiles = await (0, glob_1.glob)(path_1.default.join(directory, '**/*.xml'), { ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**'] }); const relationships = []; for (let i = 0; i < xmlFiles.length; i++) { for (let j = i + 1; j < xmlFiles.length; j++) { try { const content1 = await fs_extra_1.default.readFile(xmlFiles[i], 'utf8'); const content2 = await fs_extra_1.default.readFile(xmlFiles[j], 'utf8'); const parsed1 = this.parser.parse(content1); const parsed2 = this.parser.parse(content2); const relationship = this.findRelationship(parsed1, parsed2, relationType); if (relationship) { relationships.push({ files: [ path_1.default.relative(this.workingDirectory, xmlFiles[i]), path_1.default.relative(this.workingDirectory, xmlFiles[j]) ], relationship: relationType, details: relationship, }); } } catch (error) { // Skip files that can't be processed } } } return { content: [ { type: 'text', text: `XML File Relationships (${relationType}):\n\n` + relationships.map(rel => `${rel.files[0]}${rel.files[1]}\n ${rel.details}`).join('\n\n') || 'No relationships found.', }, ], }; } findRelationship(obj1, obj2, type) { switch (type) { case 'common_elements': const elements1 = this.getElementNames(obj1); const elements2 = this.getElementNames(obj2); const common = elements1.filter(e => elements2.includes(e)); return common.length > 0 ? `Common elements: ${common.join(', ')}` : null; case 'shared_attributes': const attrs1 = this.getAttributeNames(obj1); const attrs2 = this.getAttributeNames(obj2); const sharedAttrs = attrs1.filter(a => attrs2.includes(a)); return sharedAttrs.length > 0 ? `Shared attributes: ${sharedAttrs.join(', ')}` : null; case 'similar_structure': const structure1 = this.getStructureSignature(obj1); const structure2 = this.getStructureSignature(obj2); const similarity = this.calculateSimilarity(structure1, structure2); return similarity > 0.7 ? `Structural similarity: ${Math.round(similarity * 100)}%` : null; case 'reference_links': const refs = this.findCrossReferences(obj1, obj2); return refs.length > 0 ? `Cross-references found: ${refs.join(', ')}` : null; default: return null; } } getElementNames(obj) { const names = new Set(); const traverse = (node) => { if (typeof node === 'object' && node !== null) { Object.keys(node).forEach(key => { if (!key.startsWith('@_') && key !== '?xml') { names.add(key); } if (Array.isArray(node[key])) { node[key].forEach(traverse); } else if (typeof node[key] === 'object') { traverse(node[key]); } }); } }; traverse(obj); return Array.from(names); } getAttributeNames(obj) { const names = new Set(); const traverse = (node) => { if (typeof node === 'object' && node !== null) { Object.keys(node).forEach(key => { if (key.startsWith('@_')) { names.add(key.substring(2)); } if (Array.isArray(node[key])) { node[key].forEach(traverse); } else if (typeof node[key] === 'object') { traverse(node[key]); } }); } }; traverse(obj); return Array.from(names); } getStructureSignature(obj) { const signature = []; const traverse = (node, path = '') => { if (typeof node === 'object' && node !== null) { Object.keys(node).forEach(key => { if (!key.startsWith('@_') && key !== '?xml') { const currentPath = path ? `${path}.${key}` : key; signature.push(currentPath); if (Array.isArray(node[key])) { node[key].forEach((item, index) => { traverse(item, `${currentPath}[${index}]`); }); } else if (typeof node[key] === 'object') { traverse(node[key], currentPath); } } }); } }; traverse(obj); return signature.sort().join('|'); } calculateSimilarity(str1, str2) { const set1 = new Set(str1.split('|')); const set2 = new Set(str2.split('|')); const intersection = new Set([...set1].filter(x => set2.has(x))); const union = new Set([...set1, ...set2]); return intersection.size / union.size; } findCrossReferences(obj1, obj2) { const refs = []; const values1 = this.getAllTextValues(obj1); const values2 = this.getAllTextValues(obj2); values1.forEach(val1 => { values2.forEach(val2 => { if (val1 === val2 && val1.length > 3) { refs.push(val1); } }); }); return [...new Set(refs)]; } getAllTextValues(obj) { const values = []; const traverse = (node) => { if (typeof node === 'string') { values.push(node); } else if (typeof node === 'object' && node !== null) { Object.keys(node).forEach(key => { if (Array.isArray(node[key])) { node[key].forEach(traverse); } else { traverse(node[key]); } }); } }; traverse(obj); return values; } async validateXML(args) { const filePath = path_1.default.resolve(this.workingDirectory, args.filePath); if (!await fs_extra_1.default.pathExists(filePath)) { throw new Error(`File not found: ${filePath}`); } const content = await fs_extra_1.default.readFile(filePath, 'utf8'); const issues = []; let isValid = true; try { const parsed = this.parser.parse(content); // Basic validation checks if (!content.trim().startsWith('<')) { issues.push('XML does not start with opening tag'); isValid = false; } if (!content.trim().endsWith('>')) { issues.push('XML does not end with closing tag'); isValid = false; } // Check for balanced tags (basic check) const openTags = content.match(/<[^/][^>]*>/g) || []; const closeTags = content.match(/<\/[^>]*>/g) || []; const selfClosingTags = content.match(/<[^>]*\/>/g) || []; if (openTags.length !== closeTags.length + selfClosingTags.length) { issues.push('Mismatched opening and closing tags detected'); isValid = false; } } catch (error) { issues.push(`Parser error: ${error instanceof Error ? error.message : String(error)}`); isValid = false; } return { content: [ { type: 'text', text: `XML Validation for ${args.filePath}:\n\n` + `Status: ${isValid ? '✅ Valid' : '❌ Invalid'}\n` + (issues.length > 0 ? `Issues found:\n${issues.map(issue => `- ${issue}`).join('\n')}` : 'No issues found.'), }, ], }; } async run() { const transport = new stdio_js_1.StdioServerTransport(); await this.server.connect(transport); console.error('MCP XML Server running on stdio'); } } const server = new XMLMCPServer(); server.run().catch(console.error); //# sourceMappingURL=index.js.map