added data mcp tests

This commit is contained in:
dswbx
2025-08-12 20:22:38 +02:00
parent 871cec9251
commit bd3d2ea900
9 changed files with 425 additions and 105 deletions

View File

@@ -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";

View File

@@ -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<typeof getSystemMcp>;
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);
});
});

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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<typeof dataConfigSchema>;

View File

@@ -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<RecordToolAdditionalOptions> {}
const opts = Symbol.for("bknd-mcp-record-opts");
@@ -28,7 +37,7 @@ export class RecordToolSchema<
};
}
get mcp(): McpSchemaHelper {
get mcp(): McpSchemaHelper<RecordToolAdditionalOptions> {
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<RecordToolSchema<AP, O>>): Tool<any, any, any>[] {
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<any, any, any>[];
}

View File

@@ -21,9 +21,9 @@ export interface McpToolOptions {
resources?: Resource<any, any, any, any>[];
}
export interface SchemaWithMcpOptions {
mcp?: McpToolOptions;
}
export type SchemaWithMcpOptions<AdditionalOptions = {}> = {
mcp?: McpToolOptions & AdditionalOptions;
};
export type AppToolContext = {
app: App;
@@ -35,13 +35,13 @@ export interface McpSchema extends s.Schema {
getTools(node: s.Node<any>): Tool<any, any, any>[];
}
export class McpSchemaHelper {
export class McpSchemaHelper<AdditionalOptions = {}> {
cleanSchema: s.ObjectSchema<any, any>;
constructor(
public schema: s.Schema,
public name: string,
public options: McpToolOptions,
public options: McpToolOptions & AdditionalOptions,
) {
this.cleanSchema = this.getCleanSchema();
}