diff --git a/app/__test__/app/mcp/mcp.auth.test.ts b/app/__test__/app/mcp/mcp.auth.test.ts index 7f3643b..1ae032e 100644 --- a/app/__test__/app/mcp/mcp.auth.test.ts +++ b/app/__test__/app/mcp/mcp.auth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll } from "bun:test"; +import { describe, beforeAll } from "bun:test"; import { type App, createApp } from "core/test/utils"; import { getSystemMcp } from "modules/mcp/system-mcp"; diff --git a/app/__test__/app/mcp/mcp.data.test.ts b/app/__test__/app/mcp/mcp.data.test.ts index 4a39d25..580f729 100644 --- a/app/__test__/app/mcp/mcp.data.test.ts +++ b/app/__test__/app/mcp/mcp.data.test.ts @@ -1,38 +1,44 @@ -import { describe, test, expect, beforeAll } from "bun:test"; +import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test"; import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { getSystemMcp } from "modules/mcp/system-mcp"; +import { pickKeys } from "bknd/utils"; +import { entity, text } from "bknd"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); /** * - [ ] data_sync - * - [ ] data_entity_fn_count - * - [ ] data_entity_fn_exists - * - [ ] data_entity_read_one - * - [ ] data_entity_read_many - * - [ ] data_entity_insert - * - [ ] data_entity_update_many - * - [ ] data_entity_update_one - * - [ ] data_entity_delete_one - * - [ ] data_entity_delete_many - * - [ ] data_entity_info + * - [x] data_entity_fn_count + * - [x] data_entity_fn_exists + * - [x] data_entity_read_one + * - [x] data_entity_read_many + * - [x] data_entity_insert + * - [x] data_entity_update_many + * - [x] data_entity_update_one + * - [x] data_entity_delete_one + * - [x] data_entity_delete_many + * - [x] data_entity_info * - [ ] config_data_get * - [ ] config_data_update * - [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 - * - [ ] config_data_relations_remove - * - [ ] config_data_indices_get - * - [ ] config_data_indices_add - * - [ ] config_data_indices_update - * - [ ] config_data_indices_remove + * - [x] config_data_relations_add + * - [x] config_data_relations_get + * - [x] config_data_relations_update + * - [x] config_data_relations_remove + * - [x] config_data_indices_get + * - [x] config_data_indices_add + * - [x] config_data_indices_update + * - [x] config_data_indices_remove */ describe("mcp data", async () => { let app: App; let server: ReturnType; - beforeAll(async () => { + beforeEach(async () => { app = createApp({ initialConfig: { server: { @@ -55,6 +61,7 @@ describe("mcp data", async () => { test("config_data_entities_{add,get,update,remove}", async () => { const result = await tool(server, "config_data_entities_add", { key: "test", + return_config: true, value: {}, }); expect(result.success).toBe(true); @@ -78,6 +85,7 @@ describe("mcp data", async () => { // update const result = await tool(server, "config_data_entities_update", { key: "test", + return_config: true, value: { config: { name: "Test", @@ -100,4 +108,238 @@ describe("mcp data", async () => { expect(app.toJSON().data.entities?.test).toBeUndefined(); } }); + + test("config_data_relations_{add,get,update,remove}", async () => { + // create posts and comments + await tool(server, "config_data_entities_add", { + key: "posts", + value: {}, + }); + await tool(server, "config_data_entities_add", { + key: "comments", + value: {}, + }); + + expect(Object.keys(app.toJSON().data.entities ?? {})).toEqual(["posts", "comments"]); + + // create relation + await tool(server, "config_data_relations_add", { + key: "", // doesn't matter + value: { + type: "n:1", + source: "comments", + target: "posts", + }, + }); + + const config = app.toJSON().data; + expect( + pickKeys((config.relations?.n1_comments_posts as any) ?? {}, ["type", "source", "target"]), + ).toEqual({ + type: "n:1", + source: "comments", + target: "posts", + }); + + expect(config.entities?.comments?.fields?.posts_id?.type).toBe("relation"); + + { + // info + const postsInfo = await tool(server, "data_entity_info", { + entity: "posts", + }); + expect(postsInfo.fields).toEqual(["id"]); + expect(postsInfo.relations.all.length).toBe(1); + + const commentsInfo = await tool(server, "data_entity_info", { + entity: "comments", + }); + expect(commentsInfo.fields).toEqual(["id", "posts_id"]); + expect(commentsInfo.relations.all.length).toBe(1); + } + + // update + await tool(server, "config_data_relations_update", { + key: "n1_comments_posts", + value: { + config: { + with_limit: 10, + }, + }, + }); + expect((app.toJSON().data.relations?.n1_comments_posts?.config as any)?.with_limit).toBe(10); + + // delete + await tool(server, "config_data_relations_remove", { + key: "n1_comments_posts", + }); + expect(app.toJSON().data.relations?.n1_comments_posts).toBeUndefined(); + }); + + test("config_data_indices_update", async () => { + expect(server.tools.map((t) => t.name).includes("config_data_indices_update")).toBe(false); + }); + + test("config_data_indices_{add,get,remove}", async () => { + // create posts and comments + await tool(server, "config_data_entities_add", { + key: "posts", + value: entity("posts", { + title: text(), + content: text(), + }).toJSON(), + }); + + // add index on title + await tool(server, "config_data_indices_add", { + key: "", // auto generated + value: { + entity: "posts", + fields: ["title"], + }, + }); + + expect(app.toJSON().data.indices?.idx_posts_title).toEqual({ + entity: "posts", + fields: ["title"], + unique: false, + }); + + // delete + await tool(server, "config_data_indices_remove", { + key: "idx_posts_title", + }); + expect(app.toJSON().data.indices?.idx_posts_title).toBeUndefined(); + }); + + test("data_entity_*", async () => { + // create posts and comments + await tool(server, "config_data_entities_add", { + key: "posts", + value: entity("posts", { + title: text(), + content: text(), + }).toJSON(), + }); + await tool(server, "config_data_entities_add", { + key: "comments", + value: entity("comments", { + content: text(), + }).toJSON(), + }); + + // insert a few posts + for (let i = 0; i < 10; i++) { + await tool(server, "data_entity_insert", { + entity: "posts", + json: { + title: `Post ${i}`, + }, + }); + } + // insert a few comments + for (let i = 0; i < 5; i++) { + await tool(server, "data_entity_insert", { + entity: "comments", + json: { + content: `Comment ${i}`, + }, + }); + } + + const result = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 5, + }); + expect(result.data.length).toBe(5); + expect(result.meta.items).toBe(5); + expect(result.meta.total).toBe(10); + expect(result.data[0].title).toBe("Post 0"); + + { + // count + const result = await tool(server, "data_entity_fn_count", { + entity: "posts", + }); + expect(result.count).toBe(10); + } + + { + // exists + const res = await tool(server, "data_entity_fn_exists", { + entity: "posts", + json: { + id: result.data[0].id, + }, + }); + expect(res.exists).toBe(true); + + const res2 = await tool(server, "data_entity_fn_exists", { + entity: "posts", + json: { + id: "123", + }, + }); + expect(res2.exists).toBe(false); + } + + // update + await tool(server, "data_entity_update_one", { + entity: "posts", + id: result.data[0].id, + json: { + title: "Post 0 updated", + }, + }); + const result2 = await tool(server, "data_entity_read_one", { + entity: "posts", + id: result.data[0].id, + }); + expect(result2.data.title).toBe("Post 0 updated"); + + // delete the second post + await tool(server, "data_entity_delete_one", { + entity: "posts", + id: result.data[1].id, + }); + const result3 = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 2, + }); + expect(result3.data.map((p) => p.id)).toEqual([1, 3]); + + // update many + await tool(server, "data_entity_update_many", { + entity: "posts", + update: { + title: "Post updated", + }, + where: { + title: { $isnull: 0 }, + }, + }); + const result4 = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 10, + }); + expect(result4.data.length).toBe(9); + expect(result4.data.map((p) => p.title)).toEqual( + Array.from({ length: 9 }, () => "Post updated"), + ); + + // delete many + await tool(server, "data_entity_delete_many", { + entity: "posts", + json: { + title: { $isnull: 0 }, + }, + }); + const result5 = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 10, + }); + expect(result5.data.length).toBe(0); + expect(result5.meta.items).toBe(0); + expect(result5.meta.total).toBe(0); + }); }); diff --git a/app/package.json b/app/package.json index 730e952..d4f906f 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.3", + "jsonv-ts": "^0.7.4", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 5b04a8f..fd11281 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -445,7 +445,15 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityUpdate), - mcpTool("data_entity_update_many"), + mcpTool("data_entity_update_many", { + inputSchema: { + param: s.object({ entity: entitiesEnum }), + json: s.object({ + update: s.object({}), + where: s.object({}), + }), + }, + }), jsc("param", s.object({ entity: entitiesEnum })), jsc( "json", @@ -521,7 +529,12 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityDelete), - mcpTool("data_entity_delete_many"), + mcpTool("data_entity_delete_many", { + inputSchema: { + param: s.object({ entity: entitiesEnum }), + json: s.object({}), + }, + }), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery.properties.where), async (c) => { diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index c3ff999..f08a711 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -83,7 +83,10 @@ export const dataConfigSchema = $object("config_data", { relations: $record("config_data_relations", s.anyOf(relationsSchema), { default: {}, }).optional(), - indices: $record("config_data_indices", indicesSchema, { default: {} }).optional(), + indices: $record("config_data_indices", indicesSchema, { + default: {}, + mcp: { update: false }, + }).optional(), }).strict(); export type AppDataConfig = s.Static; diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts index 4ab7bc3..b752cfd 100644 --- a/app/src/modules/mcp/$record.ts +++ b/app/src/modules/mcp/$record.ts @@ -7,7 +7,16 @@ import { type SchemaWithMcpOptions, } from "./McpSchemaHelper"; -export interface RecordToolSchemaOptions extends s.IRecordOptions, SchemaWithMcpOptions {} +type RecordToolAdditionalOptions = { + get?: boolean; + add?: boolean; + update?: boolean; + remove?: boolean; +}; + +export interface RecordToolSchemaOptions + extends s.IRecordOptions, + SchemaWithMcpOptions {} const opts = Symbol.for("bknd-mcp-record-opts"); @@ -28,7 +37,7 @@ export class RecordToolSchema< }; } - get mcp(): McpSchemaHelper { + get mcp(): McpSchemaHelper { return this[mcpSchemaSymbol]; } @@ -104,6 +113,12 @@ export class RecordToolSchema< description: "key to add", }), value: this.getNewSchema(), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), }), }, async (params, ctx: AppToolHandlerCtx) => { @@ -122,7 +137,9 @@ export class RecordToolSchema< return ctx.json({ success: true, module: module_name, - config: ctx.context.app.module[module_name as any].config, + config: params.return_config + ? ctx.context.app.module[module_name as any].config + : undefined, }); }, ); @@ -138,6 +155,12 @@ export class RecordToolSchema< description: "key to update", }), value: this.getNewSchema(s.object({})), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), }), }, async (params, ctx: AppToolHandlerCtx) => { @@ -156,7 +179,9 @@ export class RecordToolSchema< return ctx.json({ success: true, module: module_name, - config: ctx.context.app.module[module_name as any].config, + config: params.return_config + ? ctx.context.app.module[module_name as any].config + : undefined, }); }, ); @@ -171,6 +196,12 @@ export class RecordToolSchema< key: s.string({ description: "key to remove", }), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), }), }, async (params, ctx: AppToolHandlerCtx) => { @@ -189,20 +220,22 @@ export class RecordToolSchema< return ctx.json({ success: true, module: module_name, - config: ctx.context.app.module[module_name as any].config, + config: params.return_config + ? ctx.context.app.module[module_name as any].config + : undefined, }); }, ); } getTools(node: s.Node>): Tool[] { - const { tools = [] } = this.mcp.options; + const { tools = [], get = true, add = true, update = true, remove = true } = this.mcp.options; return [ - this.toolGet(node), - this.toolAdd(node), - this.toolUpdate(node), - this.toolRemove(node), + get && this.toolGet(node), + add && this.toolAdd(node), + update && this.toolUpdate(node), + remove && this.toolRemove(node), ...tools, ].filter(Boolean) as Tool[]; } diff --git a/app/src/modules/mcp/McpSchemaHelper.ts b/app/src/modules/mcp/McpSchemaHelper.ts index 2dec0d3..a8b8b08 100644 --- a/app/src/modules/mcp/McpSchemaHelper.ts +++ b/app/src/modules/mcp/McpSchemaHelper.ts @@ -21,9 +21,9 @@ export interface McpToolOptions { resources?: Resource[]; } -export interface SchemaWithMcpOptions { - mcp?: McpToolOptions; -} +export type SchemaWithMcpOptions = { + mcp?: McpToolOptions & AdditionalOptions; +}; export type AppToolContext = { app: App; @@ -35,13 +35,13 @@ export interface McpSchema extends s.Schema { getTools(node: s.Node): Tool[]; } -export class McpSchemaHelper { +export class McpSchemaHelper { cleanSchema: s.ObjectSchema; constructor( public schema: s.Schema, public name: string, - public options: McpToolOptions, + public options: McpToolOptions & AdditionalOptions, ) { this.cleanSchema = this.getCleanSchema(); } diff --git a/bun.lock b/bun.lock index 9daf9cd..438e194 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.3", + "jsonv-ts": "^0.7.4", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2516,7 +2516,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.7.3", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-1P/ouF/a84Rc7NCXfSGPmkttyBFqemHE+5tZjb7hyaTs8MxmVUkuUO+d80/uu8sguzTnd3MmAuyuLAM0HQT4cA=="], + "jsonv-ts": ["jsonv-ts@0.7.4", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-SDx7Nt1kku6mAefrMffIdA9INqJnRLDJVooQOlstDmn0SvmTEHNAPifB+S14RR3f+Lep1T+WUeUdrHADrZsnYA=="], "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=="], diff --git a/docs/mcp.json b/docs/mcp.json index 0ee9e83..b97d05c 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -52,6 +52,7 @@ "properties": {} } ], + "$synthetic": true, "$target": "json" } } @@ -92,6 +93,7 @@ "properties": {} } ], + "$synthetic": true, "$target": "json" } } @@ -196,6 +198,7 @@ "properties": {} } ], + "$synthetic": true, "$target": "json" }, "select": { @@ -252,6 +255,7 @@ } } ], + "$synthetic": true, "$target": "json" } } @@ -264,7 +268,8 @@ "type": "object", "required": [ "entity", - "update" + "update", + "where" ], "properties": { "entity": { @@ -281,24 +286,9 @@ "properties": {} }, "where": { - "examples": [ - { - "attribute": { - "$eq": 1 - } - } - ], - "default": {}, - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": {} - } - ], - "$target": "json" + "type": "object", + "$target": "json", + "properties": {} } } } @@ -310,7 +300,8 @@ "type": "object", "required": [ "entity", - "id" + "id", + "json" ], "properties": { "entity": { @@ -331,6 +322,12 @@ } ], "$target": "param" + }, + "json": { + "type": "object", + "$synthetic": true, + "$target": "json", + "properties": {} } } } @@ -373,7 +370,8 @@ "inputSchema": { "type": "object", "required": [ - "entity" + "entity", + "json" ], "properties": { "entity": { @@ -385,24 +383,10 @@ "$target": "param" }, "json": { - "examples": [ - { - "attribute": { - "$eq": 1 - } - } - ], - "default": {}, - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": {} - } - ], - "$target": "json" + "type": "object", + "$synthetic": true, + "$target": "json", + "properties": {} } } } @@ -1907,6 +1891,11 @@ } } } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -1932,6 +1921,11 @@ "value": { "type": "object", "properties": {} + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -1953,6 +1947,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2193,6 +2192,11 @@ ] } ] + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2218,6 +2222,11 @@ "value": { "type": "object", "properties": {} + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2239,6 +2248,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2310,31 +2324,11 @@ "entity", "fields" ] - } - }, - "required": [ - "key", - "value" - ] - }, - "annotations": { - "destructiveHint": true, - "idempotentHint": true - } - }, - { - "name": "config_data_indices_update", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "key": { - "type": "string", - "description": "key to update" }, - "value": { - "type": "object", - "properties": {} + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2356,6 +2350,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2592,6 +2591,11 @@ "type", "config" ] + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2631,6 +2635,11 @@ "type", "config" ] + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2653,6 +2662,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2718,6 +2732,11 @@ "type": "boolean" } } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2743,6 +2762,11 @@ "value": { "type": "object", "properties": {} + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2764,6 +2788,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [