Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 2 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,17 @@
# 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**.

## Motivation

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
Expand Down
223 changes: 140 additions & 83 deletions lib/setModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,14 +11,153 @@ 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
})()

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
Expand Down Expand Up @@ -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
}
}
4 changes: 2 additions & 2 deletions tests/tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down