diff --git a/package.json b/package.json index 0fbe87e..86525c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "functional-models-orm-mcp", - "version": "3.2.1", + "version": "3.5.0", "description": "A functional-models-orm datastore provider that uses the @modelcontextprotocol/sdk. Great for using models on a frontend.", "main": "index.js", "types": "index.d.ts", @@ -41,7 +41,7 @@ }, "homepage": "https://github.com/monolithst/functional-models-orm-rest-client#readme", "devDependencies": { - "@cucumber/cucumber": "11.0.1", + "@cucumber/cucumber": "^11.3.0", "@eslint/compat": "^1.2.0", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.12.0", @@ -58,13 +58,14 @@ "c8": "^10.1.3", "chai": "^5.1.2", "chai-as-promised": "^8.0.1", - "cz-conventional-changelog": "^3.3.0", + "cz-conventional-changelog": "^3.0.1", "eslint": "9.14.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-functional": "~7.1.0", "eslint-plugin-import": "^2.31.0", "esprima": "^4.0.1", + "functional-models-orm-memory": "^3.0.0", "globals": "^15.12.0", "handlebars": "^4.7.8", "js-yaml": "^4.1.0", @@ -84,8 +85,10 @@ "@l4t/mcp-ai": "^1.5.0", "@modelcontextprotocol/sdk": "^1.11.4", "axios": "^1.9.0", - "functional-models": "^3.0.16", + "functional-models": "^3.5.1", + "functional-models-openapi": "^3.0.2", "lodash": "^4.17.21", - "uuid": "^11.1.0" + "uuid": "^11.1.0", + "zod": "^4.1.11" } } diff --git a/src/datastoreProvider.ts b/src/datastoreProvider.ts index ab65ceb..903cdcd 100644 --- a/src/datastoreProvider.ts +++ b/src/datastoreProvider.ts @@ -11,7 +11,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { v4 as uuidv4 } from 'uuid' import axios from 'axios' import { createOAuth2Manager } from './oauth2' -import { McpToolMeta, DatastoreProviderConfig } from './types' +import { McpToolMeta, DatastoreProviderConfig, ModelOperation } from './types' import { generateMcpToolForModelOperation } from './libs' const createTransport = ( @@ -144,7 +144,10 @@ const datastoreProvider = ( instance: ModelInstance ) => { const model = instance.getModel() - const tool = generateMcpToolForModelOperation(model as any, 'save') + const tool = generateMcpToolForModelOperation( + model as any, + ModelOperation.save + ) const input = await instance.toObj() return executeTool(tool, input) } @@ -154,7 +157,10 @@ const datastoreProvider = ( model: any, instances: readonly ModelInstance[] ) => { - const tool = generateMcpToolForModelOperation(model as any, 'bulkInsert') + const tool = generateMcpToolForModelOperation( + model as any, + ModelOperation.bulkInsert + ) const input = { items: await Promise.all(instances.map(i => i.toObj())) } await executeTool(tool, input) return @@ -162,7 +168,10 @@ const datastoreProvider = ( // RETRIEVE const retrieve = async (model: any, id: PrimaryKeyType) => { - const tool = generateMcpToolForModelOperation(model as any, 'retrieve') + const tool = generateMcpToolForModelOperation( + model as any, + ModelOperation.retrieve + ) return executeTool(tool, { id }) } @@ -171,7 +180,10 @@ const datastoreProvider = ( model: ModelType, id: PrimaryKeyType ) => { - const tool = generateMcpToolForModelOperation(model as any, 'delete') + const tool = generateMcpToolForModelOperation( + model as any, + ModelOperation.delete + ) await executeTool(tool, { id }) return } @@ -181,7 +193,10 @@ const datastoreProvider = ( model: ModelType, ormQuery: any ) => { - const tool = generateMcpToolForModelOperation(model as any, 'search') + const tool = generateMcpToolForModelOperation( + model as any, + ModelOperation.search + ) return executeTool(tool, ormQuery) } @@ -190,7 +205,10 @@ const datastoreProvider = ( model: ModelType, ids: readonly PrimaryKeyType[] ) => { - const tool = generateMcpToolForModelOperation(model as any, 'bulkDelete') + const tool = generateMcpToolForModelOperation( + model as any, + ModelOperation.bulkDelete + ) await executeTool(tool, { ids }) return } diff --git a/src/libs.ts b/src/libs.ts index 93c19f0..d194aae 100644 --- a/src/libs.ts +++ b/src/libs.ts @@ -1,336 +1,286 @@ -import { - ModelType, - PropertyConfig, - PropertyInstance, - PropertyType, -} from 'functional-models' -import { McpToolMeta, ToolNameGenerator, OpenAPISchema } from './types' - -// Default tool name generator: lower_case_with_underscores -export const defaultToolNameGenerator: ToolNameGenerator = ( - model, - operation -) => { - const def = model.getModelDefinition() - return `${def.namespace}_${def.pluralName}_${operation}` - .replace(/[^a-z0-9]+/giu, '_') - .toLowerCase() -} - -// Map a functional-models property instance to OpenAPI schema property -export const mapPropertyToOpenApi = ( - property: PropertyInstance -): { - type: string - description?: string - enum?: string[] -} => { - const typeMap: Record = { - [PropertyType.Array]: 'array', - [PropertyType.BigText]: 'string', - [PropertyType.Boolean]: 'boolean', - [PropertyType.Date]: 'string', - [PropertyType.Datetime]: 'string', - [PropertyType.Email]: 'string', - [PropertyType.Integer]: 'integer', - [PropertyType.ModelReference]: 'string', - [PropertyType.Number]: 'number', - [PropertyType.Object]: 'object', - [PropertyType.Text]: 'string', - [PropertyType.UniqueId]: 'string', - } - const type = typeMap[property.getPropertyType() as PropertyType] - if (!type) { - throw new Error(`Unsupported property type: ${property.getPropertyType()}`) - } - const config = property.getConfig() as PropertyConfig - // @ts-ignore - const desc = config?.description - const enumVals = property.getChoices() - - const result: { type: string; description?: string; enum?: string[] } = { - type, - ...(desc ? { description: desc } : {}), - ...(enumVals && enumVals.length > 0 - ? { enum: enumVals.map(x => `${x}`) } - : {}), - } - return result -} - -// Generate OpenAPI schema for a set of properties -export const generateOpenApiSchema = ( - properties: Record, - requiredFields: string[] -): OpenAPISchema => { - const props = Object.entries(properties).reduce( - (acc, [key, prop]) => { - const mapped = mapPropertyToOpenApi(prop) - const isRequired = requiredFields.includes(key) - let schema: any = { - type: mapped.type, - ...(mapped.description ? { description: mapped.description } : {}), - ...(mapped.enum ? { enum: mapped.enum } : {}), - } - if (!isRequired) { - schema.nullable = true - } - return { - ...acc, - [key]: schema, - } - }, - {} as Record - ) - return { - type: 'object', - properties: props, - required: requiredFields.length ? requiredFields : undefined, - } -} +import { ModelType } from 'functional-models' +import { McpToolMeta, OpenAPISchema, ModelOperation } from './types' export const getOrmSearchSchema = (): OpenAPISchema => { return { type: 'object', properties: { - take: { type: 'integer', description: 'Max records to return' }, - sort: { + modelType: { type: 'string', description: 'The model type' }, + query: { type: 'object', + description: 'Search query', // @ts-ignore properties: { - key: { type: 'string', description: 'Property key/column' }, - order: { - type: 'string', - enum: ['asc', 'dsc'], - description: 'Sort order (asc or dsc)', - }, - }, - required: ['key', 'order'], - description: 'Sorting statement', - }, - page: { - type: 'object', - description: 'Pagination information (any shape)', - }, - query: { - type: 'array', - description: 'Query tokens', - // @ts-ignore - items: { - oneOf: [ - { - type: 'string', - enum: ['AND', 'OR'], - description: 'Boolean query', + take: { type: 'integer', description: 'Max records to return' }, + sort: { + type: 'object', + // @ts-ignore + properties: { + key: { type: 'string', description: 'Property key/column' }, + order: { + type: 'string', + enum: ['asc', 'dsc'], + description: 'Sort order (asc or dsc)', + }, }, - { - type: 'object', - properties: { - type: { - type: 'string', - enum: ['property'], - description: 'property', - }, - key: { type: 'string' }, - value: {}, - valueType: { - type: 'string', - enum: ['string', 'number', 'date', 'object', 'boolean'], - }, - equalitySymbol: { + required: ['key', 'order'], + description: 'Sorting statement', + }, + page: { + type: 'object', + description: 'Pagination information (any shape)', + }, + query: { + type: 'array', + description: 'Query tokens', + // @ts-ignore + items: { + oneOf: [ + { type: 'string', - enum: ['=', '<', '<=', '>', '>='], + enum: ['AND', 'OR'], + description: 'Boolean query', }, - options: { + { type: 'object', properties: { - caseSensitive: { type: 'boolean' }, - startsWith: { type: 'boolean' }, - endsWith: { type: 'boolean' }, + type: { + type: 'string', + enum: ['property'], + description: 'property', + }, + key: { type: 'string' }, + value: {}, + valueType: { + type: 'string', + enum: ['string', 'number', 'date', 'object', 'boolean'], + }, + equalitySymbol: { + type: 'string', + enum: ['=', '<', '<=', '>', '>='], + }, + options: { + type: 'object', + properties: { + caseSensitive: { type: 'boolean' }, + startsWith: { type: 'boolean' }, + endsWith: { type: 'boolean' }, + }, + }, }, + required: [ + 'type', + 'key', + 'value', + 'valueType', + 'equalitySymbol', + ], }, - }, - required: ['type', 'key', 'value', 'valueType', 'equalitySymbol'], - }, - { - type: 'object', - properties: { - type: { - type: 'string', - enum: ['datesAfter'], - description: 'datesAfter', - }, - key: { type: 'string' }, - date: { type: 'string', format: 'date-time' }, - valueType: { - type: 'string', - enum: ['string', 'number', 'date', 'object', 'boolean'], - }, - options: { + { type: 'object', properties: { - equalToAndAfter: { type: 'boolean' }, + type: { + type: 'string', + enum: ['datesAfter'], + description: 'datesAfter', + }, + key: { type: 'string' }, + date: { type: 'string', format: 'date-time' }, + valueType: { + type: 'string', + enum: ['string', 'number', 'date', 'object', 'boolean'], + }, + options: { + type: 'object', + properties: { + equalToAndAfter: { type: 'boolean' }, + }, + }, }, + required: ['type', 'key', 'date', 'valueType'], }, - }, - required: ['type', 'key', 'date', 'valueType'], - }, - { - type: 'object', - properties: { - type: { - type: 'string', - enum: ['datesBefore'], - description: 'datesBefore', - }, - key: { type: 'string' }, - date: { type: 'string', format: 'date-time' }, - valueType: { - type: 'string', - enum: ['string', 'number', 'date', 'object', 'boolean'], - }, - options: { + { type: 'object', properties: { - equalToAndBefore: { type: 'boolean' }, + type: { + type: 'string', + enum: ['datesBefore'], + description: 'datesBefore', + }, + key: { type: 'string' }, + date: { type: 'string', format: 'date-time' }, + valueType: { + type: 'string', + enum: ['string', 'number', 'date', 'object', 'boolean'], + }, + options: { + type: 'object', + properties: { + equalToAndBefore: { type: 'boolean' }, + }, + }, }, + required: ['type', 'key', 'date', 'valueType'], }, - }, - required: ['type', 'key', 'date', 'valueType'], - }, - { - type: 'array', - items: { $ref: '#' }, - description: 'Nested QueryTokens', + { + type: 'array', + items: { $ref: '#' }, + description: 'Nested QueryTokens', + }, + ], }, - ], + }, }, }, }, - required: ['query'], + required: ['modelType', 'query'], } } export const getModelIdSchema = (): OpenAPISchema => { return { type: 'object', - properties: { id: { type: 'string' } }, + properties: { + modelType: { type: 'string', description: 'The model type' }, + id: { type: 'string', description: 'The model ID' }, + }, + required: ['modelType', 'id'], } } export const getModelIdArraySchema = (): OpenAPISchema => { return { type: 'object', - properties: { ids: { type: 'array' } }, - required: ['ids'], + properties: { + modelType: { type: 'string', description: 'The model type' }, + ids: { type: 'array', description: 'The model IDs' }, + }, + required: ['modelType', 'ids'], } } -export const getModelSchema = (model: ModelType): OpenAPISchema => { - const def = model.getModelDefinition() - const allProps = def.properties - const requiredFields = Object.entries(allProps) - // @ts-ignore - .filter(([, prop]) => (prop.getConfig?.() as any)?.required) - .map(([k]) => k) - return generateOpenApiSchema(allProps, requiredFields) +export const createMcpToolSave = (model?: ModelType): McpToolMeta => { + const schema = model?.getModelDefinition().schema || { type: 'object' } + return { + name: `model_save`, + description: `Save (create or update) a model record`, + inputSchema: { + type: 'object', + properties: { + modelType: { type: 'string' }, + instance: schema, + }, + required: ['modelType', 'instance'], + }, + outputSchema: schema, + } +} + +export const createMcpToolRetrieve = (model?: ModelType): McpToolMeta => { + const schema = model?.getModelDefinition().schema || { type: 'object' } + const idSchema: OpenAPISchema = getModelIdSchema() + return { + name: `model_retrieve`, + description: `Retrieve a model record by ID`, + inputSchema: idSchema, + outputSchema: { + oneOf: [schema, { type: 'null' }], + }, + } +} + +export const createMcpToolDelete = (): McpToolMeta => { + const idSchema: OpenAPISchema = getModelIdSchema() + return { + name: `model_delete`, + description: `Delete a model record by ID`, + inputSchema: idSchema, + outputSchema: { type: 'null' }, + } +} + +export const createMcpToolSearch = (model?: ModelType): McpToolMeta => { + const schema = model?.getModelDefinition().schema || { type: 'object' } + const querySchema: OpenAPISchema = getOrmSearchSchema() + return { + name: `model_search`, + description: `Search for model records`, + inputSchema: querySchema, + outputSchema: { + type: 'object', + properties: { + instances: { type: 'array', items: schema }, + page: { type: 'object' }, + }, + required: ['instances'], + }, + } +} + +export const createMcpToolBulkInsert = ( + model?: ModelType +): McpToolMeta => { + const schema = model?.getModelDefinition().schema || { type: 'object' } + return { + name: `model_bulk_insert`, + description: `Bulk insert model records`, + inputSchema: { + type: 'object', + properties: { + modelType: { type: 'string' }, + items: { + type: 'array', + items: schema, + }, + }, + required: ['modelType', 'items'], + }, + outputSchema: { type: 'null' }, + } +} + +export const createMcpToolBulkDelete = (): McpToolMeta => { + const idArraySchema: OpenAPISchema = getModelIdArraySchema() + return { + name: `model_bulk_delete`, + description: `Bulk delete model records by IDs`, + inputSchema: idArraySchema, + outputSchema: { type: 'null' }, + } } -// Main function: generate MCP tools for a model export const generateMcpToolForModelOperation = ( model: ModelType, - operation: - | 'save' - | 'retrieve' - | 'delete' - | 'search' - | 'bulkInsert' - | 'bulkDelete', - opts?: { nameGenerator?: ToolNameGenerator } + operation: ModelOperation ): McpToolMeta => { - const def = model.getModelDefinition() - const nameGen = opts?.nameGenerator || defaultToolNameGenerator - const fullSchema = getModelSchema(model) - const idSchema: OpenAPISchema = getModelIdSchema() - const idArraySchema: OpenAPISchema = getModelIdArraySchema() - const querySchema: OpenAPISchema = getOrmSearchSchema() switch (operation) { - case 'save': - return { - name: nameGen(model, 'save'), - description: `Save (create or update) a ${def.pluralName} record`, - inputSchema: fullSchema, - outputSchema: fullSchema, - } - case 'retrieve': - return { - name: nameGen(model, 'retrieve'), - description: `Retrieve a ${def.pluralName} record by ID`, - inputSchema: idSchema, - outputSchema: { - oneOf: [fullSchema, { type: 'null' }], - }, - } - case 'delete': - return { - name: nameGen(model, 'delete'), - description: `Delete a ${def.pluralName} record by ID`, - inputSchema: idSchema, - outputSchema: { type: 'null' }, - } - case 'search': - return { - name: nameGen(model, 'search'), - description: `Search for ${def.pluralName} records`, - inputSchema: querySchema, - outputSchema: { - type: 'object', - properties: { - instances: { type: 'array' }, - page: { type: 'object' }, - }, - required: ['instances'], - }, - } - case 'bulkInsert': - return { - name: nameGen(model, 'bulkInsert'), - description: `Bulk insert ${def.pluralName} records`, - inputSchema: { - type: 'object', - properties: { - items: { - type: 'array', - items: fullSchema, - }, - }, - required: ['items'], - }, - outputSchema: { type: 'null' }, - } - case 'bulkDelete': - return { - name: nameGen(model, 'bulkDelete'), - description: `Bulk delete ${def.pluralName} records by IDs`, - inputSchema: idArraySchema, - outputSchema: { type: 'null' }, - } + case ModelOperation.save: + return createMcpToolSave(model) + case ModelOperation.retrieve: + return createMcpToolRetrieve(model) + case ModelOperation.delete: + return createMcpToolDelete() + case ModelOperation.search: + return createMcpToolSearch(model) + case ModelOperation.bulkInsert: + return createMcpToolBulkInsert(model) + case ModelOperation.bulkDelete: + return createMcpToolBulkDelete() default: throw new Error(`Unknown operation: ${operation}`) } } export const generateMcpToolsForModel = ( - model: ModelType, - opts?: { nameGenerator?: ToolNameGenerator } + model: ModelType ): McpToolMeta[] => { - const operations: ( - | 'save' - | 'retrieve' - | 'delete' - | 'search' - | 'bulkInsert' - | 'bulkDelete' - )[] = ['save', 'retrieve', 'delete', 'search', 'bulkInsert', 'bulkDelete'] - return operations.map(op => generateMcpToolForModelOperation(model, op, opts)) + const operations: ModelOperation[] = [ + ModelOperation.save, + ModelOperation.retrieve, + ModelOperation.delete, + ModelOperation.search, + ModelOperation.bulkInsert, + ModelOperation.bulkDelete, + ] + return operations.map(op => generateMcpToolForModelOperation(model, op)) } diff --git a/src/types.ts b/src/types.ts index ddb0131..bdc9541 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { JsonAble, ModelType } from 'functional-models' +import { JsonAble } from 'functional-models' enum HttpMethod { get = 'get', @@ -67,10 +67,14 @@ export type DatastoreProviderConfig = { version?: string } -export type ToolNameGenerator = ( - model: ModelType, - operation: string -) => string +export enum ModelOperation { + save = 'save', + retrieve = 'retrieve', + delete = 'delete', + search = 'search', + bulkInsert = 'bulkInsert', + bulkDelete = 'bulkDelete', +} export type OpenAPISchema = Readonly<{ type: 'object' diff --git a/test/src/libs.test.ts b/test/src/libs.test.ts index d0dfd82..12b7b28 100644 --- a/test/src/libs.test.ts +++ b/test/src/libs.test.ts @@ -1,4 +1,4 @@ -import { expect } from 'chai' +import { assert } from 'chai' import { TextProperty, IntegerProperty, @@ -9,119 +9,29 @@ import { ModelReferenceProperty, NumberProperty, ObjectProperty, + ArrayProperty, + PrimaryKeyUuidProperty, } from 'functional-models' -import sinon from 'sinon' -import { - defaultToolNameGenerator, - mapPropertyToOpenApi, - generateOpenApiSchema, - generateMcpToolForModelOperation, - generateMcpToolsForModel, -} from '../../src/libs' - -describe('/src/libs.ts', () => { - const mockModel = { - getModelDefinition: () => ({ - namespace: 'Test', - pluralName: 'Tests', - properties: { - // @ts-ignore - name: TextProperty({ required: true, description: 'Name' }), - age: IntegerProperty({}), - }, - }), - } - - it('should generate the correct tool name with defaultToolNameGenerator', () => { - expect(defaultToolNameGenerator(mockModel as any, 'save')).to.equal( - 'test_tests_save' - ) - }) - - it('should map TextProperty to OpenAPI schema', () => { - // @ts-ignore - const prop = TextProperty({ description: 'desc' }) - expect(mapPropertyToOpenApi(prop)).to.deep.equal({ - type: 'string', - description: 'desc', - }) - }) - - it('should map IntegerProperty to OpenAPI schema', () => { - const prop = IntegerProperty({}) - expect(mapPropertyToOpenApi(prop)).to.deep.equal({ type: 'integer' }) - }) - - it('should map BooleanProperty to OpenAPI schema', () => { - const prop = BooleanProperty({}) - expect(mapPropertyToOpenApi(prop)).to.deep.equal({ type: 'boolean' }) - }) - - it('should map DateProperty to OpenAPI schema', () => { - const prop = DateProperty({}) - expect(mapPropertyToOpenApi(prop)).to.deep.equal({ type: 'string' }) - }) - - it('should map DatetimeProperty to OpenAPI schema', () => { - const prop = DatetimeProperty({}) - expect(mapPropertyToOpenApi(prop)).to.deep.equal({ type: 'string' }) - }) - - it('should map EmailProperty to OpenAPI schema', () => { - const prop = EmailProperty({}) - expect(mapPropertyToOpenApi(prop)).to.deep.equal({ type: 'string' }) - }) - - it('should map ModelReferenceProperty to OpenAPI schema', () => { - // @ts-ignore - const prop = ModelReferenceProperty(() => {}, {}) - expect(mapPropertyToOpenApi(prop)).to.deep.equal({ type: 'string' }) - }) - - it('should map NumberProperty to OpenAPI schema', () => { - const prop = NumberProperty({}) - expect(mapPropertyToOpenApi(prop)).to.deep.equal({ type: 'number' }) - }) - - it('should map ObjectProperty to OpenAPI schema', () => { - const prop = ObjectProperty({}) - expect(mapPropertyToOpenApi(prop)).to.deep.equal({ type: 'object' }) - }) - - it('should generate an OpenAPI schema with generateOpenApiSchema', () => { - const props = { +import { datastoreAdapter as memoryDatastore } from 'functional-models-orm-memory' +import { Model, createOrm } from 'functional-models' +import { createSchema } from 'zod-openapi' +import { z } from 'zod' + +const _setup = () => { + const datastoreAdapter = memoryDatastore.create() + const orm = createOrm({ datastoreAdapter }) + const Test1Models = orm.Model({ + pluralName: 'Test1Models', + namespace: 'functional-models-orm-memory', + properties: { + id: PrimaryKeyUuidProperty(), name: TextProperty({ required: true }), - age: IntegerProperty({}), - } - const schema = generateOpenApiSchema(props, ['name']) - expect(schema).to.have.nested.property('properties.name.type', 'string') - expect(schema).to.have.nested.property('properties.age.type', 'integer') - expect(schema.required).to.include('name') - }) - - it('should return correct tool meta with generateMcpToolForModelOperation', () => { - const meta = generateMcpToolForModelOperation(mockModel as any, 'save') - expect(meta).to.have.property('name', 'test_tests_save') - expect(meta).to.have.property('inputSchema') - expect(meta).to.have.property('outputSchema') - }) - - it('should return all tool metas with generateMcpToolsForModel', () => { - const metas = generateMcpToolsForModel(mockModel as any) - expect(metas).to.be.an('array') - expect(metas.length).to.be.greaterThan(0) - expect(metas[0]).to.have.property('name') + }, }) + return { + orm, + Test1Models, + } +} - it('should allow nullable for non-required ObjectProperty in generateOpenApiSchema', () => { - const props = { - meta: ObjectProperty({}), - requiredMeta: ObjectProperty({ required: true }), - } - const schema = generateOpenApiSchema(props, ['requiredMeta']) - // Not required: should have nullable true - expect(schema.properties.meta).to.have.property('nullable', true) - // Required: should not have nullable - expect(schema.properties.requiredMeta).to.not.have.property('nullable') - }) -}) +describe('/src/libs.ts', () => {})