Skip to main content
Glama

SAP OData to MCP Server

by Raistlin82
sap-tool-registry.ts18.9 kB
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SAPClient } from '../services/sap-client.js'; import { Logger } from '../utils/logger.js'; import { ODataService, EntityType } from '../types/sap-types.js'; import { z } from 'zod'; import { createHash } from 'node:crypto'; /** * To disable registration of specific entity tools, set the environment variable DISABLED_ENTITY_TOOLS * in your .env file. Format: ServiceId.EntityName,ServiceId.EntityName * Example: DISABLED_ENTITY_TOOLS=SALES_ORDER.OrderHeader,PRODUCT.ProductEntity * This will prevent registration of the ReadEntity tool for those entities. */ // Utility to check if ReadEntity tool registration is globally disabled function isReadEntityToolDisabled(): boolean { return process.env.DISABLE_READ_ENTITY_TOOL === 'true'; } export class SAPToolRegistry { private toolNameMapping = new Map<string, string>(); private usedShortNames = new Set<string>(); constructor( private mcpServer: McpServer, private sapClient: SAPClient, private logger: Logger, private discoveredServices: ODataService[] ) {} public registerServiceMetadataResources(): void { // Workflow guide removed - now using routing-rules.json in hierarchical-tool-registry this.mcpServer.registerResource( 'sap-service-metadata', new ResourceTemplate('sap://service/{serviceId}/metadata', { list: undefined }), { title: 'SAP Service Metadata', description: 'Metadata information for SAP OData services', }, async (uri, variables) => { // variables: Record<string, unknown> from SDK const serviceId = typeof variables.serviceId === 'string' ? variables.serviceId : ''; const service = this.discoveredServices.find(s => s.id === serviceId); if (!service) { throw new Error(`Service not found: ${serviceId}`); } return { contents: [ { uri: uri.href, text: JSON.stringify( { service: { id: service.id, title: service.title, description: service.description, url: service.url, version: service.version, }, entities: service.metadata?.entityTypes?.map(entity => ({ name: entity.name, entitySet: entity.entitySet, properties: entity.properties, keys: entity.keys, operations: { creatable: entity.creatable, updatable: entity.updatable, deletable: entity.deletable, }, })) || [], }, null, 2 ), mimeType: 'application/json', }, ], }; } ); this.mcpServer.registerResource( 'sap-services', 'sap://services', { title: 'Available SAP Services', description: 'List of all discovered SAP OData services', mimeType: 'application/json', }, async uri => ({ contents: [ { uri: uri.href, text: JSON.stringify( { services: this.discoveredServices.map(service => ({ id: service.id, title: service.title, description: service.description, entityCount: service.metadata?.entityTypes?.length || 0, })), }, null, 2 ), }, ], }) ); } public async registerServiceCRUDTools(): Promise<void> { const disableReadEntityTool = isReadEntityToolDisabled(); for (const service of this.discoveredServices) { if (!service.metadata?.entityTypes) continue; for (const entityType of service.metadata.entityTypes) { this.registerReadEntitySetTool(service, entityType); // Only registerReadEntityTool if not globally disabled if (!disableReadEntityTool) { this.registerReadEntityTool(service, entityType); } if (entityType.creatable) this.registerCreateEntityTool(service, entityType); if (entityType.updatable) this.registerUpdateEntityTool(service, entityType); if (entityType.deletable) this.registerDeleteEntityTool(service, entityType); } } } private registerReadEntitySetTool(service: ODataService, entityType: EntityType): void { const shortName = this.generateShortToolName('r', service.id, entityType.name); this.mcpServer.registerTool( shortName, { title: `List ${entityType.name} Entities`, description: `Read multiple ${entityType.name} entities from service '${service.title}' (${service.id}). ${service.description} Full identifier: read-entities--${service.id}--${entityType.name}`, inputSchema: { filter: z .string() .optional() .describe('OData $filter query parameter for filtering results'), select: z .string() .optional() .describe( `OData $select parameter. Available properties: ${entityType.properties.map(p => p.name).join(', ')}` ), expand: z.string().optional().describe('OData $expand parameter for related entities'), orderby: z.string().optional().describe('OData $orderby parameter for sorting'), top: z.number().optional().describe('OData $top parameter (limit number of results)'), skip: z.number().optional().describe('OData $skip parameter (offset for pagination)'), }, }, async (args: Record<string, unknown>) => { const queryOptions = SAPToolRegistry.buildQueryOptions(args); try { const response = await this.sapClient.readEntitySet( service.url, entityType.entitySet!, queryOptions ); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error) { this.logger.error(`Error reading entity set ${entityType.name}:`, error); return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } ); } private registerReadEntityTool(service: ODataService, entityType: EntityType): void { const keyProperties = entityType.properties.filter(p => entityType.keys.includes(p.name)); const keySchema: { [key: string]: z.ZodTypeAny } = {}; keyProperties.forEach(prop => { keySchema[prop.name] = this.getZodSchemaForODataType(prop.type).describe( `Key property: ${prop.name}` ); }); const shortName = this.generateShortToolName('rs', service.id, entityType.name); this.mcpServer.registerTool( shortName, { title: `Get ${entityType.name} Entity`, description: `Read a single ${entityType.name} entity from service '${service.title}' (${service.id}) by key properties: ${keyProperties.map(p => p.name).join(', ')}. Full identifier: read-entity--${service.id}--${entityType.name}`, inputSchema: keySchema, }, async (args: Record<string, unknown>) => { const keyValue = this.buildKeyValue(keyProperties, args); try { const response = await this.sapClient.readEntity( service.url, entityType.entitySet!, keyValue ); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error) { this.logger.error(`Error reading entity ${entityType.name}:`, error); return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } ); } private registerCreateEntityTool(service: ODataService, entityType: EntityType): void { const createSchema: { [key: string]: z.ZodTypeAny } = {}; const requiredFields: string[] = []; entityType.properties.forEach(prop => { if (!entityType.keys.includes(prop.name)) { createSchema[prop.name] = this.getZodSchemaForODataType(prop.type).describe( `${prop.name}${prop.maxLength ? ` (max length: ${prop.maxLength})` : ''}` ); if (!prop.nullable) { requiredFields.push(prop.name); } } }); const finalSchema: { [key: string]: z.ZodTypeAny } = {}; Object.entries(createSchema).forEach(([key, schema]) => { finalSchema[key] = schema.optional(); }); const shortName = this.generateShortToolName('c', service.id, entityType.name); this.mcpServer.registerTool( shortName, { title: `Create ${entityType.name} Entity`, description: `Create a new ${entityType.name} entity in service '${service.title}' (${service.id}). Required fields: ${requiredFields.join(', ')}. Available properties: ${entityType.properties .filter(p => !entityType.keys.includes(p.name)) .map(p => p.name) .join(', ')}. Full identifier: create-entity--${service.id}--${entityType.name}`, inputSchema: finalSchema, }, async (args: Record<string, unknown>) => { try { const response = await this.sapClient.createEntity( service.url, entityType.entitySet!, args ); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error) { this.logger.error(`Error creating entity ${entityType.name}:`, error); return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } ); } private registerUpdateEntityTool(service: ODataService, entityType: EntityType): void { const keyProperties = entityType.properties.filter(p => entityType.keys.includes(p.name)); const updateProperties = entityType.properties.filter(p => !entityType.keys.includes(p.name)); const updateSchema: { [key: string]: z.ZodTypeAny } = {}; keyProperties.forEach(prop => { updateSchema[prop.name] = this.getZodSchemaForODataType(prop.type).describe( `Key property: ${prop.name}` ); }); updateProperties.forEach(prop => { updateSchema[prop.name] = this.getZodSchemaForODataType(prop.type) .optional() .describe(`${prop.name}${prop.maxLength ? ` (max length: ${prop.maxLength})` : ''}`); }); const shortName = this.generateShortToolName('u', service.id, entityType.name); this.mcpServer.registerTool( shortName, { title: `Update ${entityType.name} Entity`, description: `Update an existing ${entityType.name} entity in service '${service.title}' (${service.id}). Key properties: ${keyProperties.map(p => p.name).join(', ')}. Updatable properties: ${updateProperties.map(p => p.name).join(', ')}. Full identifier: update-entity--${service.id}--${entityType.name}`, inputSchema: updateSchema, }, async (args: Record<string, unknown>) => { const keyValue = this.buildKeyValue(keyProperties, args); const updateData = { ...args }; keyProperties.forEach(prop => delete updateData[prop.name]); try { const response = await this.sapClient.updateEntity( service.url, entityType.entitySet!, keyValue, updateData ); return { content: [ { type: 'text', text: JSON.stringify(response.data, null, 2), }, ], }; } catch (error) { this.logger.error(`Error updating entity ${entityType.name}:`, error); return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } ); } private registerDeleteEntityTool(service: ODataService, entityType: EntityType): void { const keyProperties = entityType.properties.filter(p => entityType.keys.includes(p.name)); const keySchema: { [key: string]: z.ZodTypeAny } = {}; keyProperties.forEach(prop => { keySchema[prop.name] = this.getZodSchemaForODataType(prop.type).describe( `Key property: ${prop.name}` ); }); const shortName = this.generateShortToolName('d', service.id, entityType.name); this.mcpServer.registerTool( shortName, { title: `Delete ${entityType.name} Entity`, description: `Delete a ${entityType.name} entity from service '${service.title}' (${service.id}). Key properties: ${keyProperties.map(p => p.name).join(', ')}. Full identifier: delete-entity--${service.id}--${entityType.name}`, inputSchema: keySchema, }, async (args: Record<string, unknown>) => { const keyValue = this.buildKeyValue(keyProperties, args); try { await this.sapClient.deleteEntity(service.url, entityType.entitySet!, keyValue); return { content: [ { type: 'text', text: `Successfully deleted ${entityType.name} with key: ${keyValue}`, }, ], }; } catch (error) { this.logger.error(`Error deleting entity ${entityType.name}:`, error); return { content: [ { type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } ); } private buildKeyValue( keyProperties: { name: string; type: string }[], args: Record<string, unknown> ): string { if (keyProperties.length === 1) { return String(args[keyProperties[0].name]); } const keyParts = keyProperties.map(prop => `${prop.name}='${args[prop.name]}'`); return keyParts.join(','); } private getZodSchemaForODataType(odataType: string): z.ZodTypeAny { switch (odataType) { case 'Edm.String': return z.string(); case 'Edm.Int32': case 'Edm.Int16': case 'Edm.Int64': case 'Edm.Decimal': case 'Edm.Double': case 'Edm.Single': return z.number(); case 'Edm.Boolean': return z.boolean(); case 'Edm.DateTime': case 'Edm.DateTimeOffset': case 'Edm.Date': case 'Edm.Time': return z.string(); default: return z.string(); } } private generateShortToolName(operation: string, serviceId: string, entityName: string): string { let candidate = `${operation}-${serviceId}-${entityName}`; if (candidate.length <= 64) { return candidate; } const serviceAbbr = this.abbreviateServiceId(serviceId); const entityAbbr = this.abbreviateEntityName(entityName); candidate = `${operation}-${serviceAbbr}-${entityAbbr}`; if (candidate.length > 60) { const serviceHash = this.getShortHash(serviceId, 8); const entityHash = this.getShortHash(entityName, 8); candidate = `${operation}-${serviceHash}-${entityHash}`; } let finalName = candidate; let counter = 1; while (this.usedShortNames.has(finalName)) { const suffix = `-${counter}`; const maxBase = 64 - suffix.length; finalName = candidate.substring(0, maxBase) + suffix; counter++; } this.usedShortNames.add(finalName); this.toolNameMapping.set(finalName, `${operation}--${serviceId}--${entityName}`); return finalName; } private abbreviateServiceId(serviceId: string): string { let abbr = serviceId; abbr = abbr.replace(/^(ZBP_|ZC_|ZI_|YBP_|YC_|YI_)/, ''); abbr = abbr.replace(/(_SRV|_SRV_0001|_CDS|_SERVICE)(_\d+)?$/, ''); if (abbr.length > 12) { const parts = abbr.split('_').filter(p => p.length > 0); if (parts.length > 2) { abbr = parts[0] + '_' + parts[parts.length - 1]; } if (abbr.length > 12) { abbr = abbr.substring(0, 12); } } return abbr; } private abbreviateEntityName(entityName: string): string { let abbr = entityName; if (/^[A-Z][a-z]/.test(abbr)) { const capitals = abbr.match(/[A-Z]/g); if (capitals && capitals.length > 1) { abbr = capitals.join(''); } } if (abbr.length > 12) { abbr = abbr.substring(0, 12); } return abbr; } private getShortHash(input: string, length: number = 8): string { return createHash('sha256').update(input).digest('hex').substring(0, length); } /** * Clean query options by removing null and undefined values * @param args Raw arguments from MCP tool call * @returns Clean query options object */ static buildQueryOptions(args: Record<string, unknown>): Record<string, string | number> { const queryOptions: Record<string, string | number> = {}; if (args.filter !== null && args.filter !== undefined && args.filter !== '') { queryOptions.$filter = args.filter as string; } if (args.select !== null && args.select !== undefined && args.select !== '') { queryOptions.$select = args.select as string; } if (args.expand !== null && args.expand !== undefined && args.expand !== '') { queryOptions.$expand = args.expand as string; } if (args.orderby !== null && args.orderby !== undefined && args.orderby !== '') { queryOptions.$orderby = args.orderby as string; } if ( args.top !== null && args.top !== undefined && typeof args.top === 'number' && args.top > 0 ) { queryOptions.$top = args.top; } if ( args.skip !== null && args.skip !== undefined && typeof args.skip === 'number' && args.skip >= 0 ) { queryOptions.$skip = args.skip; } return queryOptions; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Raistlin82/btp-sap-odata-to-mcp-server-optimized'

If you have feedback or need assistance with the MCP directory API, please join our Discord server