metalsmith-plugin-mcp-server
Version:
MCP server for scaffolding and validating high-quality Metalsmith plugins with native methods enforcement
482 lines (421 loc) • 16.8 kB
JavaScript
/**
* Plugin Scaffold Tool
*
* This tool generates a complete Metalsmith plugin structure with enhanced standards.
* It creates everything needed for a professional plugin:
* - Source code structure with proper organization
* - Comprehensive test setup with fixtures
* - Production-ready documentation
* - Modern configuration files (ESLint, Prettier, etc.)
* - Package.json with all necessary metadata
*
* The generated plugins follow patterns from high-quality plugins like
* metalsmith-optimize-images, ensuring consistency and maintainability.
*/
import { promises as fs } from 'fs'; // File system operations (async/await style)
import path from 'path'; // Path manipulation utilities
import { fileURLToPath } from 'url'; // Convert import.meta.url to file path
import validateNpmPackageName from 'validate-npm-package-name'; // Validate npm package names
import chalk from 'chalk'; // Colored terminal output
// Our utility functions for template processing
import { copyTemplate } from '../utils/template.js';
import { generatePluginStructure } from '../utils/structure.js';
import { sanitizePath } from '../utils/path-security.js';
// Get the directory containing this file (needed for finding templates)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Scaffold a new Metalsmith plugin with enhanced standards
*
* This is the main function that Claude calls when using the plugin-scaffold tool.
* It validates inputs, creates the directory structure, and generates all files.
*
* @param {Object} args - Tool arguments from Claude
* @param {string} args.name - Plugin name (exact name as provided by user)
* @param {string} args.description - Required description of what the plugin does
* @param {string[]} [args.features=[]] - Additional features to include
* @param {string} [args.outputPath='.'] - Output directory path
* @param {string} [args.author='Your Name'] - Plugin author
* @param {string} [args.license='MIT'] - Plugin license
* @returns {Promise<Object>} MCP tool response object
*/
export async function pluginScaffoldTool(args) {
// Extract arguments with defaults (ES6 destructuring with default values)
const {
name,
features = ['async-processing'],
outputPath = '.',
license = 'MIT',
description,
author = 'Your Name'
} = args;
// Validate required parameters
if (!name) {
return {
content: [
{
type: 'text',
text: 'Plugin name is required'
}
],
isError: true
};
}
if (!description || description.trim() === '') {
return {
content: [
{
type: 'text',
text: 'Plugin description is required. Please provide a meaningful description of what the plugin does.'
}
],
isError: true
};
}
// Validate features
const validFeatures = ['async-processing', 'background-processing', 'metadata-generation'];
const invalidFeatures = features.filter((feature) => !validFeatures.includes(feature));
if (invalidFeatures.length > 0) {
return {
content: [
{
type: 'text',
text: `Invalid features: ${invalidFeatures.join(', ')}\n\nValid features are:\n- async-processing: Adds batch processing and async capabilities\n- background-processing: Adds worker thread support for concurrent processing\n- metadata-generation: Adds metadata extraction and generation features\n\nExample: ["async-processing", "metadata-generation"]`
}
],
isError: true
};
}
// Validate plugin name using npm's official validation
const validation = validateNpmPackageName(name);
if (!validation.validForNewPackages) {
// Combine errors and warnings into a single array
const errors = [...(validation.errors || []), ...(validation.warnings || [])];
// Return an MCP error response
return {
content: [
{
type: 'text',
text: `Invalid plugin name "${name}":\n${errors.join('\n')}`
}
],
isError: true // This tells Claude the operation failed
};
}
// Warn if name doesn't follow Metalsmith convention, but don't enforce it
let conventionWarning = '';
if (!name.startsWith('metalsmith-')) {
conventionWarning = `⚠️ Warning: Plugin name '${name}' doesn't follow the 'metalsmith-*' naming convention. Consider renaming to 'metalsmith-${name.replace(/^metalsmith-?/, '')}' for better discoverability.\n\n`;
}
// Sanitize the output path to prevent traversal attacks
const safeOutputPath = sanitizePath(outputPath, process.cwd());
// Construct the full path where the plugin will be created
const pluginPath = path.join(safeOutputPath, name);
try {
// Check if directory already exists to avoid overwriting existing work
try {
await fs.access(pluginPath); // This throws if path doesn't exist
// If we get here, the directory exists - that's an error
return {
content: [
{
type: 'text',
text: `Directory ${pluginPath} already exists. Please remove it or choose a different name.`
}
],
isError: true
};
} catch {
// Directory doesn't exist, which is what we want - proceed with creation
}
// Create the main plugin directory (recursive: true creates parent dirs if needed)
await fs.mkdir(pluginPath, { recursive: true });
// Generate directory structure based on plugin features
// This creates the subdirectories like src/, test/, src/utils/, etc.
const structure = generatePluginStructure(features);
await createDirectoryStructure(pluginPath, structure);
// Prepare template data for rendering
// These variables will be substituted into template files using {{variableName}} syntax
const templateData = {
pluginName: name, // Full plugin name
pluginNameShort: name.replace('metalsmith-', ''), // Short name without metalsmith prefix
features, // Array of selected features
functionName: toCamelCase(name.replace('metalsmith-', '')), // camelCase function name
className: toPascalCase(name.replace('metalsmith-', '')), // PascalCase class name
description: description.trim(),
year: new Date().getFullYear(), // Current year for copyright
license, // Use the license as-is
author, // Use provided author or default
// Feature flags for conditional template rendering
hasAsyncProcessing: features.includes('async-processing'),
hasBackgroundProcessing: features.includes('background-processing'),
hasMetadataGeneration: features.includes('metadata-generation'),
// Helper functions for templates
camelCase: toCamelCase
};
/*
* The plugin generation process involves several steps:
* 1. Copy and render template files (package.json, README.md, src/index.js, etc.)
* 2. Generate configuration files (ESLint, Prettier, .gitignore, etc.)
* 3. Initialize a git repository with an initial commit
* 4. Generate a directory tree for display to the user
*/
// Copy and render all template files with our data
await copyTemplates(pluginPath, templateData);
// Copy license file if requested
if (license !== 'UNLICENSED') {
await copyLicenseFile(pluginPath, license, templateData);
} else {
// Add a warning comment about UNLICENSED
console.warn(chalk.yellow("\n⚠️ Note: UNLICENSED means 'All Rights Reserved'"));
console.warn(chalk.yellow(' No one can use, copy, or distribute your code without explicit permission.'));
console.warn(chalk.yellow(' Consider using an open source license like MIT for Metalsmith plugins.\n'));
}
// Generate modern configuration files (ESLint flat config, etc.)
await generateConfigs(pluginPath);
// Initialize git repository and make initial commit
await initGitRepo(pluginPath);
return {
content: [
{
type: 'text',
text: [
conventionWarning,
chalk.green(`✓ Successfully created ${name}`),
'',
`Plugin created at: ${path.resolve(pluginPath)}`,
`Relative path: ${path.relative(process.cwd(), pluginPath)}`,
`Working directory: ${process.cwd()}`,
'',
'Plugin structure created with the following key files:',
'- src/index.js (main plugin file)',
'- test/index.test.js (test suite)',
'- package.json (package configuration)',
'- README.md (documentation)',
'- CLAUDE.md (AI development context)',
'- eslint.config.js (linting rules)',
'',
'Next steps:',
` cd ${path.relative(process.cwd(), pluginPath)}`,
' npm install',
' npm run build # Build ESM and CJS versions',
' npm test # Run tests for both module formats',
' npm run lint',
'',
'Available scripts:',
' npm run build - Build ESM and CJS versions',
' npm test - Run both ESM and CJS tests',
' npm run test:esm - Run ESM tests only',
' npm run test:cjs - Run CJS tests only',
' npm run coverage - Run tests with coverage',
' npm run lint - Lint code',
' npm run format - Format code',
' npm run release - Create a new release',
'',
'Development workflow:',
' 1. Make your changes in src/index.js',
' 2. Add tests in test/index.test.js',
' 3. Run npm run build to create lib/ files',
' 4. Run npm test to verify functionality',
' 5. Run npm run lint to check code style',
'',
'Note: Remember to run "npm run build" before testing or publishing!',
'',
'Happy coding! 🚀'
].join('\n')
}
]
};
} catch (error) {
// Clean up on error
try {
await fs.rm(pluginPath, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
return {
content: [
{
type: 'text',
text: `Failed to scaffold plugin: ${error.message}`
}
],
isError: true
};
}
}
/**
* Create directory structure recursively
*/
async function createDirectoryStructure(basePath, structure) {
for (const [name, content] of Object.entries(structure)) {
const fullPath = path.join(basePath, name);
if (typeof content === 'object' && content !== null) {
await fs.mkdir(fullPath, { recursive: true });
await createDirectoryStructure(fullPath, content);
}
}
}
/**
* Copy and render template files
*/
async function copyTemplates(pluginPath, data) {
const templatesDir = path.join(__dirname, '../../templates/plugin');
// Copy common templates
await copyTemplate(path.join(templatesDir, 'package.json.template'), path.join(pluginPath, 'package.json'), data);
await copyTemplate(path.join(templatesDir, 'README.md.template'), path.join(pluginPath, 'README.md'), data);
await copyTemplate(path.join(templatesDir, 'CLAUDE.md.template'), path.join(pluginPath, 'CLAUDE.md'), data);
await copyTemplate(path.join(templatesDir, 'index.js.template'), path.join(pluginPath, 'src/index.js'), data);
// Copy utility files
await copyTemplate(
path.join(templatesDir, 'utils/config.js.template'),
path.join(pluginPath, 'src/utils/config.js'),
data
);
// Copy test templates
await copyTemplate(
path.join(templatesDir, 'index.test.js.template'),
path.join(pluginPath, 'test/index.test.js'),
data
);
await copyTemplate(
path.join(templatesDir, 'cjs.test.cjs.template'),
path.join(pluginPath, 'test/cjs.test.cjs'),
data
);
// Copy test fixtures
await copyTestFixtures(templatesDir, pluginPath, data);
// Copy release script
await copyTemplate(
path.join(templatesDir, 'scripts/release.sh.template'),
path.join(pluginPath, 'scripts/release.sh'),
data
);
// Copy release notes script
await copyTemplate(
path.join(__dirname, '../../templates/scripts/release-notes.sh.template'),
path.join(pluginPath, 'scripts/release-notes.sh'),
{
...data,
GITHUB_USERNAME: 'your-username', // Template placeholder
PLUGIN_NAME: data.pluginName
}
);
// Copy GitHub workflow files
await copyGitHubWorkflows(pluginPath, data);
// Make scripts executable
await fs.chmod(path.join(pluginPath, 'scripts/release.sh'), 0o755);
await fs.chmod(path.join(pluginPath, 'scripts/release-notes.sh'), 0o755);
}
/**
* Copy GitHub workflow files for complementary CI/CD architecture
*/
async function copyGitHubWorkflows(pluginPath, data) {
const workflowsDir = path.join(__dirname, '../../templates/workflows');
const targetWorkflowsDir = path.join(pluginPath, '.github/workflows');
// Create .github/workflows directory
await fs.mkdir(targetWorkflowsDir, { recursive: true });
// Copy test workflow (CI/CD automation)
await copyTemplate(path.join(workflowsDir, 'test.yml.template'), path.join(targetWorkflowsDir, 'test.yml'), data);
// Copy Claude Code workflow (AI code review)
await copyTemplate(
path.join(workflowsDir, 'claude-code.yml.template'),
path.join(targetWorkflowsDir, 'claude-code.yml'),
data
);
}
/**
* Copy test fixture files
*/
async function copyTestFixtures(templatesDir, targetDir, data) {
const fixturesDir = path.join(templatesDir, 'fixtures');
try {
const fixtureCategories = await fs.readdir(fixturesDir);
for (const category of fixtureCategories) {
const categoryPath = path.join(fixturesDir, category);
const stats = await fs.stat(categoryPath);
if (stats.isDirectory()) {
const targetCategoryPath = path.join(targetDir, 'test/fixtures', category);
await fs.mkdir(targetCategoryPath, { recursive: true });
// Copy files from this fixture category
const files = await fs.readdir(categoryPath);
for (const file of files) {
const sourcePath = path.join(categoryPath, file);
const targetPath = path.join(targetCategoryPath, file.replace('.template', ''));
if (file.endsWith('.template')) {
await copyTemplate(sourcePath, targetPath, data);
} else {
await fs.copyFile(sourcePath, targetPath);
}
}
}
}
} catch (error) {
// Fixtures are optional, don't fail if they don't exist
console.error('Warning: Could not copy test fixtures:', error.message);
}
}
/**
* Copy license file with template rendering
*/
async function copyLicenseFile(pluginPath, license, data) {
const licensesDir = path.join(__dirname, '../../templates/licenses');
const licenseTemplate = path.join(licensesDir, `${license}.template`);
const targetPath = path.join(pluginPath, 'LICENSE');
try {
await copyTemplate(licenseTemplate, targetPath, data);
} catch (error) {
console.error(`Warning: Could not copy ${license} license template:`, error.message);
}
}
/**
* Generate configuration files
*/
async function generateConfigs(pluginPath) {
const configsDir = path.join(__dirname, '../../templates/configs');
// Copy configuration files
const configs = [
['eslint.config.js.template', 'eslint.config.js'],
['prettier.config.js.template', 'prettier.config.js'],
['.editorconfig.template', '.editorconfig'],
['.gitignore.template', '.gitignore'],
['release-it.json.template', '.release-it.json'],
['.c8rc.json.template', '.c8rc.json']
];
for (const [source, target] of configs) {
await fs.copyFile(path.join(configsDir, source), path.join(pluginPath, target));
}
}
/**
* Initialize git repository
*/
async function initGitRepo(pluginPath) {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
try {
await execAsync('git init', { cwd: pluginPath });
await execAsync('git add .', { cwd: pluginPath });
await execAsync('git commit -m "Initial commit"', { cwd: pluginPath });
} catch (error) {
// Git init is optional, don't fail if it doesn't work
console.error('Failed to initialize git repository:', error.message);
}
}
/**
* Convert string to camelCase
*/
function toCamelCase(str) {
return str
.split('-')
.map((word, index) => (index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1)))
.join('');
}
/**
* Convert string to PascalCase
*/
function toPascalCase(str) {
return str
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
}