diff --git a/README.md b/README.md index 96890f3..f7a7121 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MCP Server for CAP -A Model Context Protocol server that exposes CAP's CDS model as resources. +A Model Context Protocol server for CAP development. It's in an **alpha state**. @@ -8,18 +8,10 @@ It's in an **alpha state**. The server is supposed to help AI models answer questions like -- _Which CDS services are there in this project?_ +- _Which CDS services are there in this project and where are they served?_ - _What are the entities about?_ - _How do they relate?_ -On top, [MCP tools](https://modelcontextprotocol.io/docs/concepts/tools) could be provided that can - -- Create projects. -- Fill it will content, like adding test data, handler stubs. -- Read the application configuration. - -and more. - ## Setup ```sh diff --git a/lib/setModel.js b/lib/setModel.js index 667e093..a1347c0 100644 --- a/lib/setModel.js +++ b/lib/setModel.js @@ -3,7 +3,6 @@ import fs from 'fs' // Ensures only one CDS model compilation is ever in-flight. // The moment setModel is called, cds.model is set to a promise. -// All consumers must await cds.model if it's defined. export default async function setModel(path) { if (cds.model) { // If cds.model is a promise, await it; if it's resolved, return it @@ -12,7 +11,7 @@ export default async function setModel(path) { } // Assign a promise immediately to cds.model to prevent duplicate compilations cds.model = (async () => { - const compiled = await loadModel(path) + const compiled = await compileModel(path) cds.model = compiled return compiled })() @@ -20,6 +19,145 @@ export default async function setModel(path) { await cds.model } +// Loads and compiles the CDS model, returns the compiled model or throws on error +async function compileModel(path) { + cds.root = path + const startTime = Date.now() + const resolved = cds.resolve(path + '/*', { cache: {} }) // make sure NOT to use the cache + const compiled = await cds.load(resolved, { docs: true, locations: true }) + if (!compiled || (Array.isArray(compiled) && compiled.length === 0)) { + throw new Error(`Failed to load CDS model from path: ${path}`) + } + if (!compiled.definitions || Object.keys(compiled.definitions).length === 0) { + throw new Error(`Compiled CDS model is invalid or empty for path: ${path}`) + } + + augmentModel(compiled) + + const endTime = Date.now() + const compileDuration = endTime - startTime + + // Only do it once + if (!changeWatcher) { + const intervalMs = process.env.CDS_MCP_REFRESH_MS + ? parseInt(process.env.CDS_MCP_REFRESH_MS, 10) + : Math.max(compileDuration * 10, 20000) + changeWatcher = setInterval(async () => { + const hasChanged = await cdsFilesChanged(path) + if (hasChanged) { + await refreshModel(path) + } + }, intervalMs).unref() // Uses CDS_MCP_REFRESH_MS if set, otherwise defaults to 10x compile duration or 20s + } + return compiled +} + +// Refreshes the CDS model, only replaces cds.model if compilation succeeds +async function refreshModel(path) { + try { + const compiled = await compileModel(path) + cds.model = compiled + return compiled + } catch { + // If anything goes wrong, cds.model remains untouched + } +} + +// --- Helper functions below --- + +// Augments the compiled CDS model with endpoints and exposed entities +function augmentModel(compiled) { + for (const defName in compiled.definitions) { + // Add name for each definition + const def = compiled.definitions[defName] + def.name = defName + } + + const _entities_in = (srv, compiled) => { + const exposed = [] + const entities = Object.keys(compiled.definitions).filter(name => name.startsWith(srv.name + '.')) + for (let each of entities) { + const e = compiled.definitions[each] + if (e['@cds.autoexposed'] && !e['@cds.autoexpose']) continue + if (/DraftAdministrativeData$/.test(e.name)) continue + if (/[._]texts$/.test(e.name)) continue + // ignore for now + // if (cds.env.effective.odata.containment && service.definition._containedEntities.has(e.name)) continue + exposed.push(each) + } + return exposed + } + + // construct endpoint for each entity and add it to its definition + Object.keys(compiled.definitions) + .filter(name => compiled.definitions[name].kind === 'service') + .map(name => { + const srv = compiled.definitions[name] + srv.endpoints = getEndpoints(srv) + return srv + }) + .flatMap(srv => srv.endpoints.map(endpoint => ({ srv, endpoint }))) + .map(({ srv, endpoint }) => { + const entities = _entities_in(srv, compiled) + srv.exposedEntities = [] + for (const e of entities) { + const eRelName = e.slice(srv.name.length + 1) + srv.exposedEntities.push(eRelName) + const path = endpoint.path + eRelName.replace(/\./g, '_') + const def = compiled.definitions[e] + def.endpoints ??= [] + def.endpoints.push({ kind: endpoint.kind, path }) + } + }) +} + +// Partially taken over from @sap/cds, to avoid `compile.for.nodejs` and `compile.to.serviceinfo` +// or starting the real application. +// Custom servers (with paths defined in code) are not supported. +// TODO: Check how it works in Java. +const getEndpoints = srv => { + const _slugified = name => + /[^.]+$/ + .exec(name)[0] //> my.very.CatalogService --> CatalogService + .replace(/Service$/, '') //> CatalogService --> Catalog + .replace(/_/g, '-') //> foo_bar_baz --> foo-bar-baz + .replace(/([a-z0-9])([A-Z])/g, (_, c, C) => c + '-' + C) //> ODataFooBarX9 --> OData-Foo-Bar-X9 + .toLowerCase() //> FOO --> foo + let annos = srv['@protocol'] + if (annos) { + if (annos === 'none' || annos['='] === 'none') return [] + if (!annos.reduce) annos = [annos] + } else { + annos = [] + for (const kind of ['odata', 'rest']) { + let path = srv['@' + kind] || srv['@protocol.' + kind] + if (path) annos.push({ kind, path }) + } + } + + if (!annos.length) annos.push({ kind: 'odata' }) + + const endpoints = annos.map(each => { + let { kind = each['='] || each, path } = each + if (typeof path !== 'string') path = srv['@path'] || _slugified(srv.name) + if (path[0] !== '/') + path = + { + 'odata-v4': '/odata/v4', + odata: '/odata/v4', + 'odata-v2': '/odata/v2', + rest: '/rest', + hcql: '/hcql' + }[kind] + + '/' + + path // prefix with protocol path + if (!path.endsWith('/')) path = path + '/' + return { kind, path } + }) + + return endpoints +} + // Global cache object for CDS file timestamps const cache = { cdsFiles: new Map() } let changeWatcher = null @@ -60,84 +198,3 @@ async function cdsFilesChanged(path) { } return false } - -// Loads and compiles the CDS model, returns the compiled model or throws on error -async function loadModel(path) { - cds.root = path - const startTime = Date.now() - const resolved = cds.resolve(path + '/*', { cache: {} }) // make sure NOT to use the cache - const loaded = await cds.load(resolved, { docs: true, locations: true }) - if (!loaded || (Array.isArray(loaded) && loaded.length === 0)) { - throw new Error(`Failed to load CDS model from path: ${path}`) - } - const compiled = cds.compile.for.nodejs(loaded) - if (!compiled || !compiled.definitions || Object.keys(compiled.definitions).length === 0) { - throw new Error(`Compiled CDS model is invalid or empty for path: ${path}`) - } - const serviceInfo = cds.compile.to.serviceinfo(compiled) - - // merge with definitions - for (const info of serviceInfo) { - const def = compiled.definitions[info.name] - Object.assign(def, info) - } - - const _entities_in = service => { - const exposed = [], - { entities } = service - for (let each in entities) { - const e = entities[each] - if (e['@cds.autoexposed'] && !e['@cds.autoexpose']) continue - if (/DraftAdministrativeData$/.test(e.name)) continue - if (/[._]texts$/.test(e.name)) continue - if (cds.env.effective.odata.containment && service.definition._containedEntities.has(e.name)) continue - exposed.push(each) - } - return exposed - } - - // construct endpoint for each entity and add it to its definition - compiled.services - .flatMap(srv => srv.endpoints.map(endpoint => ({ srv, endpoint }))) - .map(({ srv, endpoint }) => { - const entities = _entities_in(srv) - for (const e of entities) { - const path = endpoint.path + e.replace(/\./g, '_') - const def = compiled.definitions[srv.name + '.' + e] - def.endpoints ??= [] - def.endpoints.push({ kind: endpoint.kind, path }) - // Add fully qualified entity names to each service as 'exposedEntities' - for (const service of compiled.services) { - service.exposedEntities = _entities_in(service).map(shortName => service.name + '.' + shortName) - } - } - }) - - const endTime = Date.now() - const compileDuration = endTime - startTime - - // Only do it once - if (!changeWatcher) { - const intervalMs = process.env.CDS_MCP_REFRESH_MS - ? parseInt(process.env.CDS_MCP_REFRESH_MS, 10) - : Math.max(compileDuration * 10, 20000) - changeWatcher = setInterval(async () => { - const hasChanged = await cdsFilesChanged(path) - if (hasChanged) { - await refreshModel(path) - } - }, intervalMs).unref() // Uses CDS_MCP_REFRESH_MS if set, otherwise defaults to 10x compile duration or 20s - } - return compiled -} - -// Refreshes the CDS model, only replaces cds.model if compilation succeeds -async function refreshModel(path) { - try { - const compiled = await loadModel(path) - cds.model = compiled - return compiled - } catch { - // If anything goes wrong, cds.model remains untouched - } -} diff --git a/tests/tools.test.js b/tests/tools.test.js index be281f7..80da385 100644 --- a/tests/tools.test.js +++ b/tests/tools.test.js @@ -20,7 +20,7 @@ test.describe('tools', () => { assert(Array.isArray(result[0].endpoints), 'Should contain endpoints') assert.equal(result[0].name, 'AdminService', 'Should find Adminservice.Books service') assert.equal(result[0].endpoints[0].kind, 'odata', 'Should contain odata endpoint kind') - assert.equal(result[0].endpoints[0].path, 'odata/v4/admin/', 'Should contain endpoint path') + assert.equal(result[0].endpoints[0].path, '/odata/v4/admin/', 'Should contain endpoint path') }) test('search_cds_definitions: fuzzy search for Books entity', async () => { @@ -35,7 +35,7 @@ test.describe('tools', () => { assert(books[0].name, 'AdminService.Books', 'Should find AdminService.Books entity') assert(Array.isArray(books[0].endpoints), 'Should contain endpoints') assert.equal(books[0].endpoints[0].kind, 'odata', 'Should contain odata endpoint kind') - assert.equal(books[0].endpoints[0].path, 'odata/v4/admin/Books', 'Should contain endpoint path') + assert.equal(books[0].endpoints[0].path, '/odata/v4/admin/Books', 'Should contain endpoint path') }) test('list_all_cds_definition_names: should list all entities', async () => {