diff --git a/app/__test__/app/mcp/mcp.data.test.ts b/app/__test__/app/mcp/mcp.data.test.ts index 23bf496..4a39d25 100644 --- a/app/__test__/app/mcp/mcp.data.test.ts +++ b/app/__test__/app/mcp/mcp.data.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeAll } from "bun:test"; -import { type App, createApp } from "core/test/utils"; +import { describe, test, expect, beforeAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { getSystemMcp } from "modules/mcp/system-mcp"; /** @@ -16,10 +16,10 @@ import { getSystemMcp } from "modules/mcp/system-mcp"; * - [ ] data_entity_info * - [ ] config_data_get * - [ ] config_data_update - * - [ ] config_data_entities_get - * - [ ] config_data_entities_add - * - [ ] config_data_entities_update - * - [ ] config_data_entities_remove + * - [x] config_data_entities_get + * - [x] config_data_entities_add + * - [x] config_data_entities_update + * - [x] config_data_entities_remove * - [ ] config_data_relations_get * - [ ] config_data_relations_add * - [ ] config_data_relations_update @@ -44,5 +44,60 @@ describe("mcp data", async () => { }); await app.build(); server = getSystemMcp(app); + server.setLogLevel("error"); + server.onNotification((message) => { + console.dir(message, { depth: null }); + }); + }); + + const tool = createMcpToolCaller(); + + test("config_data_entities_{add,get,update,remove}", async () => { + const result = await tool(server, "config_data_entities_add", { + key: "test", + value: {}, + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(result.config.entities.test.type).toEqual("regular"); + + const entities = Object.keys(app.toJSON().data.entities ?? {}); + expect(entities).toContain("test"); + + { + // get + const result = await tool(server, "config_data_entities_get", { + key: "test", + }); + expect(result.module).toBe("data"); + expect(result.key).toBe("test"); + expect(result.value.type).toEqual("regular"); + } + + { + // update + const result = await tool(server, "config_data_entities_update", { + key: "test", + value: { + config: { + name: "Test", + }, + }, + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(result.config.entities.test.config?.name).toEqual("Test"); + expect(app.toJSON().data.entities?.test?.config?.name).toEqual("Test"); + } + + { + // remove + const result = await tool(server, "config_data_entities_remove", { + key: "test", + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(app.toJSON().data.entities?.test).toBeUndefined(); + } }); }); diff --git a/app/package.json b/app/package.json index 34c8bbd..a175e79 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.7.2", + "jsonv-ts": "^0.7.3", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/core/test/utils.ts b/app/src/core/test/utils.ts index 5702724..19eaa63 100644 --- a/app/src/core/test/utils.ts +++ b/app/src/core/test/utils.ts @@ -24,6 +24,7 @@ export function createMcpToolCaller() { }); if ((res.result as any)?.isError) { + console.dir(res.result, { depth: null }); throw new Error((res.result as any)?.content?.[0]?.text ?? "Unknown error"); } diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index 6394ff9..a44a4ee 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -3,7 +3,7 @@ import { MediaField, mediaFieldConfigSchema } from "../media/MediaField"; import { FieldClassMap } from "data/fields"; import { RelationClassMap, RelationFieldClassMap } from "data/relations"; import { entityConfigSchema, entityTypes } from "data/entities"; -import { primaryFieldTypes } from "./fields"; +import { primaryFieldTypes, baseFieldConfigSchema } from "./fields"; import { s } from "bknd/utils"; import { $object, $record } from "modules/mcp"; @@ -12,6 +12,7 @@ export const FIELDS = { ...RelationFieldClassMap, media: { schema: mediaFieldConfigSchema, field: MediaField }, }; +export const FIELD_TYPES = Object.keys(FIELDS); export type FieldType = keyof typeof FIELDS; export const RELATIONS = RelationClassMap; @@ -40,6 +41,19 @@ export const entitiesSchema = s.strictObject({ fields: entityFields.optional(), }); export type TAppDataEntity = s.Static; +export const simpleEntitiesSchema = s.strictObject({ + type: s.string({ enum: entityTypes, default: "regular" }).optional(), + config: entityConfigSchema.optional(), + fields: s + .record( + s.object({ + type: s.anyOf([s.string({ enum: FIELD_TYPES }), s.string()]), + config: baseFieldConfigSchema.optional(), + }), + { default: {} }, + ) + .optional(), +}); export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => { return s.strictObject( @@ -70,6 +84,6 @@ export const dataConfigSchema = $object("config_data", { default: {}, }).optional(), indices: $record("config_data_indices", indicesSchema, { default: {} }).optional(), -}); +}).strict(); export type AppDataConfig = s.Static; diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts index 2e60651..4ab7bc3 100644 --- a/app/src/modules/mcp/$record.ts +++ b/app/src/modules/mcp/$record.ts @@ -32,6 +32,10 @@ export class RecordToolSchema< return this[mcpSchemaSymbol]; } + private getNewSchema(fallback: s.Schema = this.additionalProperties) { + return this[opts].new_schema ?? fallback; + } + private toolGet(node: s.Node>) { return new Tool( [this.mcp.name, "get"].join("_"), @@ -60,8 +64,10 @@ export class RecordToolSchema< async (params, ctx: AppToolHandlerCtx) => { const configs = ctx.context.app.toJSON(params.secrets); const config = getPath(configs, node.instancePath); + const [module_name] = node.instancePath; // @todo: add schema to response + const schema = params.schema ? this.getNewSchema().toJSON() : undefined; if (params.key) { if (!(params.key in config)) { @@ -70,15 +76,19 @@ export class RecordToolSchema< const value = getPath(config, params.key); return ctx.json({ secrets: params.secrets ?? false, + module: module_name, key: params.key, value: value ?? null, + schema, }); } return ctx.json({ secrets: params.secrets ?? false, + module: module_name, key: null, value: config ?? null, + schema, }); }, ); @@ -93,20 +103,26 @@ export class RecordToolSchema< key: s.string({ description: "key to add", }), - value: this[opts].new_schema ?? this.additionalProperties, + value: this.getNewSchema(), }), }, async (params, ctx: AppToolHandlerCtx) => { const configs = ctx.context.app.toJSON(); const config = getPath(configs, node.instancePath); + const [module_name, ...rest] = node.instancePath; if (params.key in config) { throw new Error(`Key "${params.key}" already exists in config`); } + await ctx.context.app + .mutateConfig(module_name as any) + .patch([...rest, params.key], params.value); + return ctx.json({ - key: params.key, - value: params.value ?? null, + success: true, + module: module_name, + config: ctx.context.app.module[module_name as any].config, }); }, ); @@ -121,22 +137,26 @@ export class RecordToolSchema< key: s.string({ description: "key to update", }), - value: this[opts].new_schema ?? s.object({}), + value: this.getNewSchema(s.object({})), }), }, async (params, ctx: AppToolHandlerCtx) => { const configs = ctx.context.app.toJSON(params.secrets); const config = getPath(configs, node.instancePath); + const [module_name, ...rest] = node.instancePath; if (!(params.key in config)) { throw new Error(`Key "${params.key}" not found in config`); } - const value = getPath(config, params.key); + await ctx.context.app + .mutateConfig(module_name as any) + .patch([...rest, params.key], params.value); + return ctx.json({ - updated: false, - key: params.key, - value: value ?? null, + success: true, + module: module_name, + config: ctx.context.app.module[module_name as any].config, }); }, ); @@ -156,14 +176,20 @@ export class RecordToolSchema< async (params, ctx: AppToolHandlerCtx) => { const configs = ctx.context.app.toJSON(); const config = getPath(configs, node.instancePath); + const [module_name, ...rest] = node.instancePath; if (!(params.key in config)) { throw new Error(`Key "${params.key}" not found in config`); } + await ctx.context.app + .mutateConfig(module_name as any) + .remove([...rest, params.key].join(".")); + return ctx.json({ - removed: false, - key: params.key, + success: true, + module: module_name, + config: ctx.context.app.module[module_name as any].config, }); }, ); diff --git a/bun.lock b/bun.lock index d139a29..0756dc5 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.7.2", + "jsonv-ts": "^0.7.3", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2511,7 +2511,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.7.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-HxtHbMQhReJpxDIWHcM+kLekRLgJIo+drQnxiXep9thbh5jA44pd3DxwApEV1/oTufH2xAfDV6uu6O0Fd4s9lA=="], + "jsonv-ts": ["jsonv-ts@0.7.3", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-1P/ouF/a84Rc7NCXfSGPmkttyBFqemHE+5tZjb7hyaTs8MxmVUkuUO+d80/uu8sguzTnd3MmAuyuLAM0HQT4cA=="], "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],