spartacus-mcp-server
Version:
MCP server for SAP Spartacus development tools - Generate components, services, and models following Spartacus conventions
586 lines (561 loc) • 24.7 kB
JavaScript
/**
* SAP Spartacus MCP Server
*
* This MCP server provides tools for creating custom components in SAP Spartacus
* following the established patterns and conventions.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
import { promises as fs } from 'fs';
import * as path from 'path';
class SpartacusMCPServer {
server;
constructor() {
this.server = new Server({
name: 'spartacus-mcp-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
this.setupToolHandlers();
}
setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'create_spartacus_component',
description: 'Create a new Spartacus component with all necessary files following Spartacus conventions',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Component name in PascalCase (e.g., ProductIntro)',
},
selector: {
type: 'string',
description: 'Component selector (e.g., cx-product-intro)',
},
category: {
type: 'string',
description: 'Component category (product, user, content, navigation, etc.)',
},
outputPath: {
type: 'string',
description: 'Output directory path',
},
hasModule: {
type: 'boolean',
description: 'Whether to create a module file',
default: true,
},
hasService: {
type: 'boolean',
description: 'Whether to create a service file',
default: false,
},
hasModel: {
type: 'boolean',
description: 'Whether to create a model file',
default: false,
},
cmsComponent: {
type: 'string',
description: 'CMS component name for configuration',
},
dependencies: {
type: 'array',
items: { type: 'string' },
description: 'Additional dependencies to import',
},
},
required: ['name', 'selector', 'category', 'outputPath'],
},
},
{
name: 'analyze_spartacus_structure',
description: 'Analyze the Spartacus project structure and provide insights',
inputSchema: {
type: 'object',
properties: {
projectPath: {
type: 'string',
description: 'Path to the Spartacus project',
},
},
required: ['projectPath'],
},
},
{
name: 'generate_spartacus_service',
description: 'Generate a Spartacus service following the established patterns',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Service name in PascalCase',
},
outputPath: {
type: 'string',
description: 'Output directory path',
},
injectable: {
type: 'boolean',
description: 'Whether the service should be injectable',
default: true,
},
dependencies: {
type: 'array',
items: { type: 'string' },
description: 'Service dependencies',
},
},
required: ['name', 'outputPath'],
},
},
{
name: 'create_spartacus_model',
description: 'Create TypeScript interfaces and models for Spartacus',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Model name in PascalCase',
},
outputPath: {
type: 'string',
description: 'Output directory path',
},
properties: {
type: 'object',
description: 'Model properties with their types',
},
extends: {
type: 'string',
description: 'Interface to extend from',
},
},
required: ['name', 'outputPath'],
},
},
],
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case 'create_spartacus_component':
return this.createSpartacusComponent(request.params.arguments);
case 'analyze_spartacus_structure':
return this.analyzeSpartacusStructure(request.params.arguments);
case 'generate_spartacus_service':
return this.generateSpartacusService(request.params.arguments);
case 'create_spartacus_model':
return this.createSpartacusModel(request.params.arguments);
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
}
});
}
async createSpartacusComponent(args) {
try {
const { name, selector, category, outputPath, hasModule = true, hasService = false, hasModel = false, cmsComponent, dependencies = [], } = args;
const kebabName = this.toKebabCase(name);
const componentDir = path.join(outputPath, kebabName);
// Create directory
await fs.mkdir(componentDir, { recursive: true });
const files = [];
// Generate component files
const componentTs = this.generateComponentTs(name, selector, dependencies);
await fs.writeFile(path.join(componentDir, `${kebabName}.component.ts`), componentTs);
files.push(`${kebabName}.component.ts`);
const componentHtml = this.generateComponentHtml(name, selector);
await fs.writeFile(path.join(componentDir, `${kebabName}.component.html`), componentHtml);
files.push(`${kebabName}.component.html`);
const componentScss = this.generateComponentScss(selector);
await fs.writeFile(path.join(componentDir, `${kebabName}.component.scss`), componentScss);
files.push(`${kebabName}.component.scss`);
const componentSpec = this.generateComponentSpec(name);
await fs.writeFile(path.join(componentDir, `${kebabName}.component.spec.ts`), componentSpec);
files.push(`${kebabName}.component.spec.ts`);
if (hasModule) {
const moduleTs = this.generateModuleTs(name, cmsComponent);
await fs.writeFile(path.join(componentDir, `${kebabName}.module.ts`), moduleTs);
files.push(`${kebabName}.module.ts`);
}
if (hasService) {
const serviceTs = this.generateServiceTs(name, true, dependencies);
await fs.writeFile(path.join(componentDir, `${kebabName}.service.ts`), serviceTs);
files.push(`${kebabName}.service.ts`);
const serviceSpec = this.generateServiceSpec(name);
await fs.writeFile(path.join(componentDir, `${kebabName}.service.spec.ts`), serviceSpec);
files.push(`${kebabName}.service.spec.ts`);
}
if (hasModel) {
const modelTs = this.generateModelTs(name, {}, undefined);
await fs.writeFile(path.join(componentDir, `${kebabName}.model.ts`), modelTs);
files.push(`${kebabName}.model.ts`);
}
const indexTs = this.generateIndexTs(name, hasModule, hasService, hasModel);
await fs.writeFile(path.join(componentDir, 'index.ts'), indexTs);
files.push('index.ts');
return {
content: [
{
type: 'text',
text: `✅ Successfully created Spartacus component '${name}'\n\n` +
`📁 Component directory: ${componentDir}\n\n` +
`📋 Generated files:\n${files.map(f => ` - ${f}`).join('\n')}\n\n` +
`🎯 Category: ${category}\n` +
`🏷️ Selector: ${selector}\n\n` +
`Next steps:\n` +
`1. Import the component module in your app module\n` +
`2. Add the component to your templates\n` +
`3. Customize the component logic and styling\n` +
`4. Run tests to ensure everything works correctly`,
},
],
};
}
catch (error) {
throw new McpError(ErrorCode.InternalError, `Failed to create component: ${error instanceof Error ? error.message : String(error)}`);
}
}
async analyzeSpartacusStructure(args) {
try {
const { projectPath } = args;
// Check if path exists
try {
await fs.access(projectPath);
}
catch {
throw new Error(`Project path does not exist: ${projectPath}`);
}
const analysis = {
projectPath,
structure: {},
recommendations: [],
spartacusVersion: 'Unknown',
angularVersion: 'Unknown',
};
// Analyze package.json
try {
const packageJsonPath = path.join(projectPath, 'package.json');
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(packageJsonContent);
// Extract versions
const deps = { ...packageJson.dependencies, ...packageJson.devDependencies };
analysis.spartacusVersion = deps['@spartacus/core'] || 'Not found';
analysis.angularVersion = deps['@angular/core'] || 'Not found';
analysis.structure = {
hasPackageJson: true,
dependencies: Object.keys(packageJson.dependencies || {}),
devDependencies: Object.keys(packageJson.devDependencies || {}),
};
}
catch (error) {
analysis.recommendations.push('⚠️ package.json not found or invalid');
}
// Check for common Spartacus directories
const commonDirs = ['src/app', 'src/assets', 'src/styles'];
for (const dir of commonDirs) {
try {
await fs.access(path.join(projectPath, dir));
analysis.structure[dir.replace('/', '_')] = true;
}
catch {
analysis.structure[dir.replace('/', '_')] = false;
analysis.recommendations.push(`📁 Consider creating ${dir} directory`);
}
}
return {
content: [
{
type: 'text',
text: `📊 Spartacus Project Analysis\n\n` +
`📍 Project Path: ${projectPath}\n` +
`🏷️ Spartacus Version: ${analysis.spartacusVersion}\n` +
`🅰️ Angular Version: ${analysis.angularVersion}\n\n` +
`📁 Project Structure:\n${JSON.stringify(analysis.structure, null, 2)}\n\n` +
`💡 Recommendations:\n${analysis.recommendations.map(r => ` ${r}`).join('\n') || ' ✅ No issues found'}\n\n` +
`🔍 Analysis completed successfully!`,
},
],
};
}
catch (error) {
throw new McpError(ErrorCode.InternalError, `Failed to analyze project: ${error instanceof Error ? error.message : String(error)}`);
}
}
async generateSpartacusService(args) {
try {
const { name, outputPath, injectable = true, dependencies = [] } = args;
const kebabName = this.toKebabCase(name);
const serviceDir = path.join(outputPath, kebabName);
await fs.mkdir(serviceDir, { recursive: true });
const files = [];
const serviceTs = this.generateServiceTs(name, injectable, dependencies);
await fs.writeFile(path.join(serviceDir, `${kebabName}.service.ts`), serviceTs);
files.push(`${kebabName}.service.ts`);
const serviceSpec = this.generateServiceSpec(name);
await fs.writeFile(path.join(serviceDir, `${kebabName}.service.spec.ts`), serviceSpec);
files.push(`${kebabName}.service.spec.ts`);
return {
content: [
{
type: 'text',
text: `✅ Successfully created Spartacus service '${name}'\n\n` +
`📁 Service directory: ${serviceDir}\n\n` +
`📋 Generated files:\n${files.map(f => ` - ${f}`).join('\n')}\n\n` +
`🔧 Injectable: ${injectable}\n` +
`📦 Dependencies: ${dependencies.length > 0 ? dependencies.join(', ') : 'None'}\n\n` +
`Next steps:\n` +
`1. Implement service methods\n` +
`2. Add service to providers in module\n` +
`3. Inject service in components that need it\n` +
`4. Write unit tests for service methods`,
},
],
};
}
catch (error) {
throw new McpError(ErrorCode.InternalError, `Failed to create service: ${error instanceof Error ? error.message : String(error)}`);
}
}
async createSpartacusModel(args) {
try {
const { name, outputPath, properties = {}, extends: extendsInterface } = args;
const kebabName = this.toKebabCase(name);
const modelPath = path.join(outputPath, `${kebabName}.model.ts`);
await fs.mkdir(outputPath, { recursive: true });
const modelTs = this.generateModelTs(name, properties, extendsInterface);
await fs.writeFile(modelPath, modelTs);
return {
content: [
{
type: 'text',
text: `✅ Successfully created Spartacus model '${name}'\n\n` +
`📁 Model file: ${modelPath}\n\n` +
`🏗️ Properties: ${Object.keys(properties).length > 0 ? Object.keys(properties).join(', ') : 'None defined'}\n` +
`🔗 Extends: ${extendsInterface || 'None'}\n\n` +
`Next steps:\n` +
`1. Add additional properties as needed\n` +
`2. Import model in components and services\n` +
`3. Use model for type safety\n` +
`4. Consider adding validation if needed`,
},
],
};
}
catch (error) {
throw new McpError(ErrorCode.InternalError, `Failed to create model: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Template generation methods
generateComponentTs(name, selector, dependencies = []) {
const dependencyImports = dependencies.length > 0
? dependencies.map(dep => `import { ${dep} } from '@spartacus/core';`).join('\n') + '\n'
: '';
return `/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
import { ChangeDetectionStrategy, Component } from '@angular/core';
${dependencyImports}
@Component({
selector: '${selector}',
templateUrl: './${this.toKebabCase(name)}.component.html',
styleUrls: ['./${this.toKebabCase(name)}.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ${name}Component {
constructor() {
// TODO: Implement component logic
}
// TODO: Add component methods
}
`;
}
generateComponentHtml(name, selector) {
return `<!--
SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
SPDX-License-Identifier: Apache-2.0
-->
<div class="${selector.replace('cx-', '')}">
<h2>{{ '${name.toLowerCase()}.title' | cxTranslate }}</h2>
<!-- TODO: Add component template -->
<p>{{ '${name.toLowerCase()}.description' | cxTranslate }}</p>
</div>
`;
}
generateComponentScss(selector) {
return `/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
.${selector.replace('cx-', '')} {
// TODO: Add component styles using Spartacus design tokens
h2 {
margin-bottom: 1rem;
font-weight: 600;
}
p {
color: var(--cx-color-text);
line-height: 1.5;
}
}
`;
}
generateComponentSpec(name) {
return `/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ${name}Component } from './${this.toKebabCase(name)}.component';
describe('${name}Component', () => {
let component: ${name}Component;
let fixture: ComponentFixture<${name}Component>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [${name}Component],
}).compileComponents();
fixture = TestBed.createComponent(${name}Component);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
// TODO: Add more specific tests
});
`;
}
generateModuleTs(name, cmsComponent) {
const cmsConfig = cmsComponent ? `
import { CmsConfig } from '@spartacus/core';
const cmsComponents: CmsConfig = {
cmsComponents: {
${cmsComponent}: {
component: ${name}Component,
},
},
};` : '';
const cmsImports = cmsComponent ? `
ConfigModule.withConfig(cmsComponents),` : '';
return `/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ConfigModule } from '@spartacus/core';
import { ${name}Component } from './${this.toKebabCase(name)}.component';
${cmsConfig}
@NgModule({
declarations: [${name}Component],
imports: [
CommonModule,${cmsImports}
],
exports: [${name}Component],
})
export class ${name}Module {}
`;
}
generateServiceTs(name, injectable = true, dependencies = []) {
const injectableDecorator = injectable ? '@Injectable({\n providedIn: \'root\',\n})\n' : '';
const dependencyImports = dependencies.length > 0
? dependencies.map(dep => `import { ${dep} } from '@spartacus/core';`).join('\n') + '\n'
: '';
return `/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
import { Injectable } from '@angular/core';
${dependencyImports}
${injectableDecorator}export class ${name}Service {
constructor() {
// TODO: Inject dependencies
}
// TODO: Add service methods
}
`;
}
generateServiceSpec(name) {
return `/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
import { TestBed } from '@angular/core/testing';
import { ${name}Service } from './${this.toKebabCase(name)}.service';
describe('${name}Service', () => {
let service: ${name}Service;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(${name}Service);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
// TODO: Add more specific tests
});
`;
}
generateModelTs(name, properties = {}, extendsInterface) {
const extendsClause = extendsInterface ? ` extends ${extendsInterface}` : '';
const propertyLines = Object.entries(properties)
.map(([key, type]) => ` ${key}: ${type};`)
.join('\n');
return `/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
export interface ${name}${extendsClause} {
${propertyLines || ' // TODO: Add interface properties'}
}
`;
}
generateIndexTs(name, hasModule, hasService, hasModel) {
const exports = [`export * from './${this.toKebabCase(name)}.component';`];
if (hasModule) {
exports.push(`export * from './${this.toKebabCase(name)}.module';`);
}
if (hasService) {
exports.push(`export * from './${this.toKebabCase(name)}.service';`);
}
if (hasModel) {
exports.push(`export * from './${this.toKebabCase(name)}.model';`);
}
return `/*
* SPDX-FileCopyrightText: 2025 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
${exports.join('\n')}
`;
}
toKebabCase(str) {
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase();
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Spartacus MCP server running on stdio');
}
}
const server = new SpartacusMCPServer();
server.run().catch(console.error);
//# sourceMappingURL=spartacus-mcp-server.js.map