diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..064ec5c --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "bknd": { + "url": "http://localhost:3000/mcp", + "headers": { + "API_KEY": "value" + } + } + } +} diff --git a/app/__test__/app/App.spec.ts b/app/__test__/app/App.spec.ts index 361bbef..aff3e53 100644 --- a/app/__test__/app/App.spec.ts +++ b/app/__test__/app/App.spec.ts @@ -20,6 +20,7 @@ describe("App", () => { "guard", "flags", "logger", + "mcp", "helper", ]); }, @@ -135,4 +136,21 @@ describe("App", () => { // expect async listeners to be executed sync after request expect(called).toHaveBeenCalled(); }); + + test("getMcpClient", async () => { + const app = createApp({ + initialConfig: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + const client = app.getMcpClient(); + const res = await client.listTools(); + expect(res).toBeDefined(); + expect(res?.tools.length).toBeGreaterThan(0); + }); }); diff --git a/app/__test__/app/AppServer.spec.ts b/app/__test__/app/AppServer.spec.ts index 40ea414..43cf220 100644 --- a/app/__test__/app/AppServer.spec.ts +++ b/app/__test__/app/AppServer.spec.ts @@ -13,6 +13,9 @@ describe("AppServer", () => { allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], }, + mcp: { + enabled: false, + }, }); } @@ -31,6 +34,9 @@ describe("AppServer", () => { allow_methods: ["GET", "POST"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], }, + mcp: { + enabled: false, + }, }); } }); diff --git a/app/__test__/app/mcp/mcp.auth.test.ts b/app/__test__/app/mcp/mcp.auth.test.ts new file mode 100644 index 0000000..ea02274 --- /dev/null +++ b/app/__test__/app/mcp/mcp.auth.test.ts @@ -0,0 +1,226 @@ +import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import type { McpServer } from "bknd/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +/** + * - [x] auth_me + * - [x] auth_strategies + * - [x] auth_user_create + * - [x] auth_user_token + * - [x] auth_user_password_change + * - [x] auth_user_password_test + * - [x] config_auth_get + * - [x] config_auth_update + * - [x] config_auth_strategies_get + * - [x] config_auth_strategies_add + * - [x] config_auth_strategies_update + * - [x] config_auth_strategies_remove + * - [x] config_auth_roles_get + * - [x] config_auth_roles_add + * - [x] config_auth_roles_update + * - [x] config_auth_roles_remove + */ +describe("mcp auth", async () => { + let app: App; + let server: McpServer; + beforeEach(async () => { + app = createApp({ + initialConfig: { + auth: { + enabled: true, + jwt: { + secret: "secret", + }, + }, + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + server = app.mcp!; + server.setLogLevel("error"); + server.onNotification((message) => { + console.dir(message, { depth: null }); + }); + }); + + const tool = createMcpToolCaller(); + + test("auth_*", async () => { + const me = await tool(server, "auth_me", {}); + expect(me.user).toBeNull(); + + // strategies + const strategies = await tool(server, "auth_strategies", {}); + expect(Object.keys(strategies.strategies).length).toEqual(1); + expect(strategies.strategies.password.enabled).toBe(true); + + // create user + const user = await tool( + server, + "auth_user_create", + { + email: "test@test.com", + password: "12345678", + }, + new Headers(), + ); + expect(user.email).toBe("test@test.com"); + + // create token + const token = await tool( + server, + "auth_user_token", + { + email: "test@test.com", + }, + new Headers(), + ); + expect(token.token).toBeDefined(); + expect(token.user.email).toBe("test@test.com"); + + // me + const me2 = await tool( + server, + "auth_me", + {}, + new Request("http://localhost", { + headers: new Headers({ + Authorization: `Bearer ${token.token}`, + }), + }), + ); + expect(me2.user.email).toBe("test@test.com"); + + // change password + const changePassword = await tool( + server, + "auth_user_password_change", + { + email: "test@test.com", + password: "87654321", + }, + new Headers(), + ); + expect(changePassword.changed).toBe(true); + + // test password + const testPassword = await tool( + server, + "auth_user_password_test", + { + email: "test@test.com", + password: "87654321", + }, + new Headers(), + ); + expect(testPassword.valid).toBe(true); + }); + + test("config_auth_{get,update}", async () => { + expect(await tool(server, "config_auth_get", {})).toEqual({ + path: "", + secrets: false, + partial: false, + value: app.toJSON().auth, + }); + + // update + await tool(server, "config_auth_update", { + value: { + allow_register: false, + }, + }); + expect(app.toJSON().auth.allow_register).toBe(false); + }); + + test("config_auth_strategies_{get,add,update,remove}", async () => { + const strategies = await tool(server, "config_auth_strategies_get", { + key: "password", + }); + expect(strategies).toEqual({ + secrets: false, + module: "auth", + key: "password", + value: { + enabled: true, + type: "password", + }, + }); + + // add google oauth + const addGoogleOauth = await tool(server, "config_auth_strategies_add", { + key: "google", + value: { + type: "oauth", + enabled: true, + config: { + name: "google", + type: "oidc", + client: { + client_id: "client_id", + client_secret: "client_secret", + }, + }, + }, + return_config: true, + }); + expect(addGoogleOauth.config.google.enabled).toBe(true); + expect(app.toJSON().auth.strategies.google?.enabled).toBe(true); + + // update (disable) google oauth + await tool(server, "config_auth_strategies_update", { + key: "google", + value: { + enabled: false, + }, + }); + expect(app.toJSON().auth.strategies.google?.enabled).toBe(false); + + // remove google oauth + await tool(server, "config_auth_strategies_remove", { + key: "google", + }); + expect(app.toJSON().auth.strategies.google).toBeUndefined(); + }); + + test("config_auth_roles_{get,add,update,remove}", async () => { + // add role + const addGuestRole = await tool(server, "config_auth_roles_add", { + key: "guest", + value: { + permissions: ["read", "write"], + }, + return_config: true, + }); + expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]); + + // update role + await tool(server, "config_auth_roles_update", { + key: "guest", + value: { + permissions: ["read"], + }, + }); + expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]); + + // get role + const getGuestRole = await tool(server, "config_auth_roles_get", { + key: "guest", + }); + expect(getGuestRole.value.permissions).toEqual(["read"]); + + // remove role + await tool(server, "config_auth_roles_remove", { + key: "guest", + }); + expect(app.toJSON().auth.roles?.guest).toBeUndefined(); + }); +}); diff --git a/app/__test__/app/mcp/mcp.base.test.ts b/app/__test__/app/mcp/mcp.base.test.ts new file mode 100644 index 0000000..df8bdeb --- /dev/null +++ b/app/__test__/app/mcp/mcp.base.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "bun:test"; +import { createApp } from "core/test/utils"; +import { registries } from "index"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; + +describe("mcp", () => { + it("should have tools", async () => { + registries.media.register("local", StorageLocalAdapter); + + const app = createApp({ + initialConfig: { + auth: { + enabled: true, + }, + media: { + enabled: true, + adapter: { + type: "local", + config: { + path: "./", + }, + }, + }, + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + + expect(app.mcp?.tools.length).toBeGreaterThan(0); + }); +}); diff --git a/app/__test__/app/mcp/mcp.data.test.ts b/app/__test__/app/mcp/mcp.data.test.ts new file mode 100644 index 0000000..69b5106 --- /dev/null +++ b/app/__test__/app/mcp/mcp.data.test.ts @@ -0,0 +1,346 @@ +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, type McpServer } from "bknd/utils"; +import { entity, text } from "bknd"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +/** + * - [ ] data_sync + * - [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 + * - [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: McpServer; + beforeEach(async () => { + const time = performance.now(); + app = createApp({ + initialConfig: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + server = app.mcp!; + 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", + return_config: true, + value: {}, + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(result.config.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", + return_config: true, + value: { + config: { + name: "Test", + }, + }, + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(result.config.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(); + } + }); + + 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/__test__/app/mcp/mcp.media.test.ts b/app/__test__/app/mcp/mcp.media.test.ts new file mode 100644 index 0000000..c10e8bb --- /dev/null +++ b/app/__test__/app/mcp/mcp.media.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import { getSystemMcp } from "modules/mcp/system-mcp"; +import { registries } from "index"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; +import type { McpServer } from "bknd/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +/** + * - [x] config_media_get + * - [x] config_media_update + * - [x] config_media_adapter_get + * - [x] config_media_adapter_update + */ +describe("mcp media", async () => { + let app: App; + let server: McpServer; + beforeEach(async () => { + registries.media.register("local", StorageLocalAdapter); + app = createApp({ + initialConfig: { + media: { + enabled: true, + adapter: { + type: "local", + config: { + path: "./", + }, + }, + }, + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + server = app.mcp!; + server.setLogLevel("error"); + server.onNotification((message) => { + console.dir(message, { depth: null }); + }); + }); + + const tool = createMcpToolCaller(); + + test("config_media_{get,update}", async () => { + const result = await tool(server, "config_media_get", {}); + expect(result).toEqual({ + path: "", + secrets: false, + partial: false, + value: app.toJSON().media, + }); + + // partial + expect((await tool(server, "config_media_get", { path: "adapter" })).value).toEqual({ + type: "local", + config: { + path: "./", + }, + }); + + // update + await tool(server, "config_media_update", { + value: { + storage: { + body_max_size: 1024 * 1024 * 10, + }, + }, + return_config: true, + }); + expect(app.toJSON().media.storage.body_max_size).toBe(1024 * 1024 * 10); + }); + + test("config_media_adapter_{get,update}", async () => { + const result = await tool(server, "config_media_adapter_get", {}); + expect(result).toEqual({ + secrets: false, + value: app.toJSON().media.adapter, + }); + + // update + await tool(server, "config_media_adapter_update", { + value: { + type: "local", + config: { + path: "./subdir", + }, + }, + }); + const adapter = app.toJSON().media.adapter as any; + expect(adapter.config.path).toBe("./subdir"); + expect(adapter.type).toBe("local"); + + // set to s3 + { + await tool(server, "config_media_adapter_update", { + value: { + type: "s3", + config: { + access_key: "123", + secret_access_key: "456", + url: "https://example.com/what", + }, + }, + }); + + const adapter = app.toJSON(true).media.adapter as any; + expect(adapter.type).toBe("s3"); + expect(adapter.config.url).toBe("https://example.com/what"); + } + }); +}); diff --git a/app/__test__/app/mcp/mcp.server.test.ts b/app/__test__/app/mcp/mcp.server.test.ts new file mode 100644 index 0000000..3ada557 --- /dev/null +++ b/app/__test__/app/mcp/mcp.server.test.ts @@ -0,0 +1,72 @@ +import { describe, test, expect, beforeAll, mock, beforeEach, afterAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import type { McpServer } from "bknd/utils"; + +/** + * - [x] config_server_get + * - [x] config_server_update + */ +describe("mcp system", async () => { + let app: App; + let server: McpServer; + beforeAll(async () => { + app = createApp({ + initialConfig: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + server = app.mcp!; + }); + + const tool = createMcpToolCaller(); + + test("config_server_get", async () => { + const result = await tool(server, "config_server_get", {}); + expect(JSON.parse(JSON.stringify(result))).toEqual({ + path: "", + secrets: false, + partial: false, + value: JSON.parse(JSON.stringify(app.toJSON().server)), + }); + }); + + test("config_server_get2", async () => { + const result = await tool(server, "config_server_get", {}); + expect(JSON.parse(JSON.stringify(result))).toEqual({ + path: "", + secrets: false, + partial: false, + value: JSON.parse(JSON.stringify(app.toJSON().server)), + }); + }); + + test("config_server_update", async () => { + const original = JSON.parse(JSON.stringify(app.toJSON().server)); + const result = await tool(server, "config_server_update", { + value: { + cors: { + origin: "http://localhost", + }, + }, + return_config: true, + }); + + expect(JSON.parse(JSON.stringify(result))).toEqual({ + success: true, + module: "server", + config: { + ...original, + cors: { + ...original.cors, + origin: "http://localhost", + }, + }, + }); + expect(app.toJSON().server.cors.origin).toBe("http://localhost"); + }); +}); diff --git a/app/__test__/app/mcp/mcp.system.test.ts b/app/__test__/app/mcp/mcp.system.test.ts new file mode 100644 index 0000000..6b08628 --- /dev/null +++ b/app/__test__/app/mcp/mcp.system.test.ts @@ -0,0 +1,56 @@ +import { AppEvents } from "App"; +import { describe, test, expect, beforeAll, mock } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import type { McpServer } from "bknd/utils"; + +/** + * - [x] system_config + * - [x] system_build + * - [x] system_ping + * - [x] system_info + */ +describe("mcp system", async () => { + let app: App; + let server: McpServer; + beforeAll(async () => { + app = createApp({ + initialConfig: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + server = app.mcp!; + }); + + const tool = createMcpToolCaller(); + + test("system_ping", async () => { + const result = await tool(server, "system_ping", {}); + expect(result).toEqual({ pong: true }); + }); + + test("system_info", async () => { + const result = await tool(server, "system_info", {}); + expect(Object.keys(result).length).toBeGreaterThan(0); + expect(Object.keys(result)).toContainValues(["version", "runtime", "connection"]); + }); + + test("system_build", async () => { + const called = mock(() => null); + + app.emgr.onEvent(AppEvents.AppBuiltEvent, () => void called(), { once: true }); + + const result = await tool(server, "system_build", {}); + expect(called).toHaveBeenCalledTimes(1); + expect(result.success).toBe(true); + }); + + test("system_config", async () => { + const result = await tool(server, "system_config", {}); + expect(result).toEqual(app.toJSON()); + }); +}); diff --git a/app/__test__/data/specs/Entity.spec.ts b/app/__test__/data/specs/Entity.spec.ts index 064db2d..220b688 100644 --- a/app/__test__/data/specs/Entity.spec.ts +++ b/app/__test__/data/specs/Entity.spec.ts @@ -47,8 +47,4 @@ describe("[data] Entity", async () => { entity.addField(field); expect(entity.getField("new_field")).toBe(field); }); - - test.only("types", async () => { - console.log(entity.toTypes()); - }); }); diff --git a/app/__test__/debug/jsonv-resolution.test.ts b/app/__test__/debug/jsonv-resolution.test.ts new file mode 100644 index 0000000..64b60e4 --- /dev/null +++ b/app/__test__/debug/jsonv-resolution.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "bun:test"; +import * as sDirect from "jsonv-ts"; +import { s as sFromBknd } from "bknd/utils"; + +describe("jsonv-ts resolution", () => { + it("should resolve to a single instance", () => { + const sameNamespace = sDirect === (sFromBknd as unknown as typeof sDirect); + // If this fails, two instances are being loaded via different specifiers/paths + expect(sameNamespace).toBe(true); + }); + + it("should resolve specifiers to a single package path", async () => { + const base = await import.meta.resolve("jsonv-ts"); + const hono = await import.meta.resolve("jsonv-ts/hono"); + const mcp = await import.meta.resolve("jsonv-ts/mcp"); + expect(typeof base).toBe("string"); + expect(typeof hono).toBe("string"); + expect(typeof mcp).toBe("string"); + // They can be different files (subpath exports), but they should share the same package root + const pkgRoot = (p: string) => p.slice(0, p.lastIndexOf("jsonv-ts") + "jsonv-ts".length); + expect(pkgRoot(base)).toBe(pkgRoot(hono)); + expect(pkgRoot(base)).toBe(pkgRoot(mcp)); + }); +}); diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index 1760d32..2579a88 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -5,7 +5,7 @@ import { format as sqlFormat } from "sql-formatter"; import type { em as protoEm } from "../src/data/prototype"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { slugify } from "core/utils/strings"; +import { slugify } from "bknd/utils"; import { type Connection, SqliteLocalConnection } from "data/connection"; import { EntityManager } from "data/entities/EntityManager"; diff --git a/app/__test__/modules/module-test-suite.ts b/app/__test__/modules/module-test-suite.ts index 99dfcf5..1f19f4e 100644 --- a/app/__test__/modules/module-test-suite.ts +++ b/app/__test__/modules/module-test-suite.ts @@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it } from "bun:test"; import { Hono } from "hono"; import { Guard } from "auth/authorize/Guard"; -import { DebugLogger } from "core/utils/DebugLogger"; import { EventManager } from "core/events"; import { EntityManager } from "data/entities/EntityManager"; import { Module, type ModuleBuildContext } from "modules/Module"; import { getDummyConnection } from "../helper"; import { ModuleHelper } from "modules/ModuleHelper"; +import { DebugLogger, McpServer } from "bknd/utils"; export function makeCtx(overrides?: Partial): ModuleBuildContext { const { dummyConnection } = getDummyConnection(); @@ -19,6 +19,7 @@ export function makeCtx(overrides?: Partial): ModuleBuildCon guard: new Guard(), flags: Module.ctx_flags, logger: new DebugLogger(false), + mcp: new McpServer(), ...overrides, }; return { diff --git a/app/__test__/ui/json-form.spec.ts b/app/__test__/ui/json-form.spec.ts index c1331f2..331d6eb 100644 --- a/app/__test__/ui/json-form.spec.ts +++ b/app/__test__/ui/json-form.spec.ts @@ -102,7 +102,9 @@ describe("json form", () => { ] satisfies [string, Exclude, boolean][]; for (const [pointer, schema, output] of examples) { - expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output); + expect(utils.isRequired(new Draft2019(schema), pointer, schema), `${pointer} `).toBe( + output, + ); } }); diff --git a/app/build.cli.ts b/app/build.cli.ts index e874813..999a6a1 100644 --- a/app/build.cli.ts +++ b/app/build.cli.ts @@ -1,6 +1,24 @@ import pkg from "./package.json" with { type: "json" }; import c from "picocolors"; -import { formatNumber } from "core/utils"; +import { formatNumber } from "bknd/utils"; +import * as esbuild from "esbuild"; + +if (process.env.DEBUG) { + await esbuild.build({ + entryPoints: ["./src/cli/index.ts"], + outdir: "./dist/cli", + platform: "node", + minify: false, + format: "esm", + bundle: true, + external: ["jsonv-ts", "jsonv-ts/*"], + define: { + __isDev: "0", + __version: JSON.stringify(pkg.version), + }, + }); + process.exit(0); +} const result = await Bun.build({ entrypoints: ["./src/cli/index.ts"], @@ -8,6 +26,7 @@ const result = await Bun.build({ outdir: "./dist/cli", env: "PUBLIC_*", minify: true, + external: ["jsonv-ts", "jsonv-ts/*"], define: { __isDev: "0", __version: JSON.stringify(pkg.version), diff --git a/app/build.ts b/app/build.ts index 998132c..7586c66 100644 --- a/app/build.ts +++ b/app/build.ts @@ -69,6 +69,8 @@ const external = [ "@libsql/client", "bknd", /^bknd\/.*/, + "jsonv-ts", + /^jsonv-ts\/.*/, ] as const; /** diff --git a/app/bunfig.toml b/app/bunfig.toml index 6f4fe9a..c39b588 100644 --- a/app/bunfig.toml +++ b/app/bunfig.toml @@ -2,4 +2,5 @@ #registry = "http://localhost:4873" [test] -coverageSkipTestFiles = true \ No newline at end of file +coverageSkipTestFiles = true +console.depth = 10 \ No newline at end of file diff --git a/app/internal/docs.build-assets.ts b/app/internal/docs.build-assets.ts new file mode 100644 index 0000000..127def7 --- /dev/null +++ b/app/internal/docs.build-assets.ts @@ -0,0 +1,35 @@ +import { createApp } from "bknd/adapter/bun"; + +async function generate() { + console.info("Generating MCP documentation..."); + const app = await createApp({ + initialConfig: { + server: { + mcp: { + enabled: true, + }, + }, + auth: { + enabled: true, + }, + media: { + enabled: true, + adapter: { + type: "local", + config: { + path: "./", + }, + }, + }, + }, + }); + await app.build(); + + const res = await app.server.request("/mcp?explain=1"); + const { tools, resources } = await res.json(); + await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2)); + + console.info("MCP documentation generated."); +} + +void generate(); diff --git a/app/package.json b/app/package.json index 97aee9a..2e316f7 100644 --- a/app/package.json +++ b/app/package.json @@ -43,7 +43,8 @@ "test:e2e:adapters": "bun run e2e/adapters.ts", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", - "test:e2e:report": "playwright show-report" + "test:e2e:report": "playwright show-report", + "docs:build-assets": "bun internal/docs.build-assets.ts" }, "license": "FSL-1.1-MIT", "dependencies": { @@ -64,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.3.2", + "jsonv-ts": "^0.8.1", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/App.ts b/app/src/App.ts index 7f84a6a..ae0ee55 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,5 +1,5 @@ import type { CreateUserPayload } from "auth/AppAuth"; -import { $console } from "bknd/utils"; +import { $console, McpClient } from "bknd/utils"; import { Event } from "core/events"; import type { em as prototypeEm } from "data/prototype"; import { Connection } from "data/connection/Connection"; @@ -96,6 +96,7 @@ export class App(module: Module, config: ModuleConfigs[Module]) { // if the EventManager was disabled, we assume we shouldn't // respond to events, such as "onUpdated". diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index df8cd03..4ee9a8e 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,4 +1,4 @@ -import type { DB } from "bknd"; +import type { DB, PrimaryFieldType } from "bknd"; import * as AuthPermissions from "auth/auth-permissions"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; @@ -87,6 +87,7 @@ export class AppAuth extends Module { super.setBuilt(); this._controller = new AuthController(this); + this._controller.registerMcp(); this.ctx.server.route(this.config.basepath, this._controller.getController()); this.ctx.guard.registerPermissions(AuthPermissions); } @@ -176,6 +177,32 @@ export class AppAuth extends Module { return created; } + async changePassword(userId: PrimaryFieldType, newPassword: string) { + const users_entity = this.config.entity_name as "users"; + const { data: user } = await this.em.repository(users_entity).findId(userId); + if (!user) { + throw new Error("User not found"); + } else if (user.strategy !== "password") { + throw new Error("User is not using password strategy"); + } + + const togglePw = (visible: boolean) => { + const field = this.em.entity(users_entity).field("strategy_value")!; + + field.config.hidden = !visible; + field.config.fillable = visible; + }; + + const pw = this.authenticator.strategy("password" as const) as PasswordStrategy; + togglePw(true); + await this.em.mutator(users_entity).updateOne(user.id, { + strategy_value: await pw.hash(newPassword), + }); + togglePw(false); + + return true; + } + override toJSON(secrets?: boolean): AppAuthSchema { if (!this.config.enabled) { return this.configDefault; diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index b039635..d54d1e2 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,11 +1,20 @@ -import type { SafeUser } from "bknd"; +import type { DB, SafeUser } from "bknd"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { AppAuth } from "auth/AppAuth"; import * as AuthPermissions from "auth/auth-permissions"; import * as DataPermissions from "data/permissions"; import type { Hono } from "hono"; import { Controller, type ServerEnv } from "modules/Controller"; -import { describeRoute, jsc, s, parse, InvalidSchemaError, transformObject } from "bknd/utils"; +import { + describeRoute, + jsc, + s, + parse, + InvalidSchemaError, + transformObject, + mcpTool, +} from "bknd/utils"; +import type { PasswordStrategy } from "auth/authenticate/strategies"; export type AuthActionResponse = { success: boolean; @@ -118,6 +127,9 @@ export class AuthController extends Controller { summary: "Get the current user", tags: ["auth"], }), + mcpTool("auth_me", { + noErrorCodes: [403], + }), auth(), async (c) => { const claims = c.get("auth")?.user; @@ -159,6 +171,7 @@ export class AuthController extends Controller { summary: "Get the available authentication strategies", tags: ["auth"], }), + mcpTool("auth_strategies"), jsc("query", s.object({ include_disabled: s.boolean().optional() })), async (c) => { const { include_disabled } = c.req.valid("query"); @@ -188,4 +201,118 @@ export class AuthController extends Controller { return hono; } + + override registerMcp(): void { + const { mcp } = this.auth.ctx; + + const getUser = async (params: { id?: string | number; email?: string }) => { + let user: DB["users"] | undefined = undefined; + if (params.id) { + const { data } = await this.userRepo.findId(params.id); + user = data; + } else if (params.email) { + const { data } = await this.userRepo.findOne({ email: params.email }); + user = data; + } + if (!user) { + throw new Error("User not found"); + } + return user; + }; + + mcp.tool( + // @todo: needs permission + "auth_user_create", + { + description: "Create a new user", + inputSchema: s.object({ + email: s.string({ format: "email" }), + password: s.string({ minLength: 8 }), + role: s + .string({ + enum: Object.keys(this.auth.config.roles ?? {}), + }) + .optional(), + }), + }, + async (params, c) => { + await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c); + + return c.json(await this.auth.createUser(params)); + }, + ); + + mcp.tool( + // @todo: needs permission + "auth_user_token", + { + description: "Get a user token", + inputSchema: s.object({ + id: s.anyOf([s.string(), s.number()]).optional(), + email: s.string({ format: "email" }).optional(), + }), + }, + async (params, c) => { + await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c); + + const user = await getUser(params); + return c.json({ user, token: await this.auth.authenticator.jwt(user) }); + }, + ); + + mcp.tool( + // @todo: needs permission + "auth_user_password_change", + { + description: "Change a user's password", + inputSchema: s.object({ + id: s.anyOf([s.string(), s.number()]).optional(), + email: s.string({ format: "email" }).optional(), + password: s.string({ minLength: 8 }), + }), + }, + async (params, c) => { + await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c); + + const user = await getUser(params); + if (!(await this.auth.changePassword(user.id, params.password))) { + throw new Error("Failed to change password"); + } + return c.json({ changed: true }); + }, + ); + + mcp.tool( + // @todo: needs permission + "auth_user_password_test", + { + description: "Test a user's password", + inputSchema: s.object({ + email: s.string({ format: "email" }), + password: s.string({ minLength: 8 }), + }), + }, + async (params, c) => { + await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c); + + const pw = this.auth.authenticator.strategy("password") as PasswordStrategy; + const controller = pw.getController(this.auth.authenticator); + + const res = await controller.request( + new Request("https://localhost/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: params.email, + password: params.password, + }), + }), + ); + + return c.json({ valid: res.ok }); + }, + ); + } } diff --git a/app/src/auth/auth-permissions.ts b/app/src/auth/auth-permissions.ts index ed57c50..8b097e7 100644 --- a/app/src/auth/auth-permissions.ts +++ b/app/src/auth/auth-permissions.ts @@ -2,3 +2,6 @@ import { Permission } from "core/security/Permission"; export const createUser = new Permission("auth.user.create"); //export const updateUser = new Permission("auth.user.update"); +export const testPassword = new Permission("auth.user.password.test"); +export const changePassword = new Permission("auth.user.password.change"); +export const createToken = new Permission("auth.user.token.create"); diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index aedce2d..4fd40a4 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -1,6 +1,7 @@ import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; import { objectTransform, s } from "bknd/utils"; +import { $object, $record } from "modules/mcp"; export const Strategies = { password: { @@ -45,7 +46,8 @@ export const guardRoleSchema = s.strictObject({ implicit_allow: s.boolean().optional(), }); -export const authConfigSchema = s.strictObject( +export const authConfigSchema = $object( + "config_auth", { enabled: s.boolean({ default: false }), basepath: s.string({ default: "/api/auth" }), @@ -53,20 +55,29 @@ export const authConfigSchema = s.strictObject( allow_register: s.boolean({ default: true }).optional(), jwt: jwtConfig, cookie: cookieConfig, - strategies: s.record(strategiesSchema, { - title: "Strategies", - default: { - password: { - type: "password", - enabled: true, - config: { - hashing: "sha256", + strategies: $record( + "config_auth_strategies", + strategiesSchema, + { + title: "Strategies", + default: { + password: { + type: "password", + enabled: true, + config: { + hashing: "sha256", + }, }, }, }, - }), + s.strictObject({ + type: s.string(), + enabled: s.boolean({ default: true }).optional(), + config: s.object({}), + }), + ), guard: guardConfigSchema.optional(), - roles: s.record(guardRoleSchema, { default: {} }).optional(), + roles: $record("config_auth_roles", guardRoleSchema, { default: {} }).optional(), }, { title: "Authentication" }, ); diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 46dfc04..0350aba 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -9,6 +9,7 @@ import type { ServerEnv } from "modules/Controller"; import { pick } from "lodash-es"; import { InvalidConditionsException } from "auth/errors"; import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils"; +import { $object } from "modules/mcp"; import type { AuthStrategy } from "./strategies/Strategy"; type Input = any; // workaround @@ -42,7 +43,7 @@ export interface UserPool { const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds export const cookieConfig = s - .object({ + .strictObject({ path: s.string({ default: "/" }), sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), secure: s.boolean({ default: true }), @@ -53,27 +54,24 @@ export const cookieConfig = s pathSuccess: s.string({ default: "/" }), pathLoggedOut: s.string({ default: "/" }), }) - .partial() - .strict(); + .partial(); // @todo: maybe add a config to not allow cookie/api tokens to be used interchangably? // see auth.integration test for further details -export const jwtConfig = s - .object( - { - // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth - secret: secret({ default: "" }), - alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(), - expires: s.number().optional(), // seconds - issuer: s.string().optional(), - fields: s.array(s.string(), { default: ["id", "email", "role"] }), - }, - { - default: {}, - }, - ) - .strict(); +export const jwtConfig = s.strictObject( + { + secret: secret({ default: "" }), + alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(), + expires: s.number().optional(), // seconds + issuer: s.string().optional(), + fields: s.array(s.string(), { default: ["id", "email", "role"] }), + }, + { + default: {}, + }, +); + export const authenticatorConfig = s.object({ jwt: jwtConfig, cookie: cookieConfig, @@ -378,13 +376,28 @@ export class Authenticator< } // @todo: don't extract user from token, but from the database or cache - async resolveAuthFromRequest(c: Context): Promise { - let token: string | undefined; - if (c.req.raw.headers.has("Authorization")) { - const bearerHeader = String(c.req.header("Authorization")); - token = bearerHeader.replace("Bearer ", ""); + async resolveAuthFromRequest(c: Context | Request | Headers): Promise { + let headers: Headers; + let is_context = false; + if (c instanceof Headers) { + headers = c; + } else if (c instanceof Request) { + headers = c.headers; } else { - token = await this.getAuthCookie(c); + is_context = true; + try { + headers = c.req.raw.headers; + } catch (e) { + throw new Exception("Request/Headers/Context is required to resolve auth", 400); + } + } + + let token: string | undefined; + if (headers.has("Authorization")) { + const bearerHeader = String(headers.get("Authorization")); + token = bearerHeader.replace("Bearer ", ""); + } else if (is_context) { + token = await this.getAuthCookie(c as Context); } if (token) { diff --git a/app/src/cli/commands/index.ts b/app/src/cli/commands/index.ts index 9f63382..8e5b8b9 100644 --- a/app/src/cli/commands/index.ts +++ b/app/src/cli/commands/index.ts @@ -6,3 +6,4 @@ export { user } from "./user"; export { create } from "./create"; export { copyAssets } from "./copy-assets"; export { types } from "./types"; +export { mcp } from "./mcp/mcp"; diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts new file mode 100644 index 0000000..23e7357 --- /dev/null +++ b/app/src/cli/commands/mcp/mcp.ts @@ -0,0 +1,84 @@ +import type { CliCommand } from "cli/types"; +import { makeAppFromEnv } from "../run"; +import { getSystemMcp } from "modules/mcp/system-mcp"; +import { $console, stdioTransport } from "bknd/utils"; + +export const mcp: CliCommand = (program) => + program + .command("mcp") + .description("mcp server stdio transport") + .option("--config ", "config file") + .option("--db-url ", "database url, can be any valid sqlite url") + .option( + "--token ", + "token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable", + ) + .option("--verbose", "verbose output") + .option("--log-level ", "log level") + .option("--force", "force enable mcp") + .action(action); + +async function action(options: { + verbose?: boolean; + config?: string; + dbUrl?: string; + token?: string; + logLevel?: string; + force?: boolean; +}) { + const verbose = !!options.verbose; + const __oldConsole = { ...console }; + + // disable console + if (!verbose) { + $console.disable(); + Object.entries(console).forEach(([key]) => { + console[key] = () => null; + }); + } + + const app = await makeAppFromEnv({ + config: options.config, + dbUrl: options.dbUrl, + server: "node", + }); + + if (!app.modules.get("server").config.mcp.enabled && !options.force) { + $console.enable(); + Object.assign(console, __oldConsole); + console.error("MCP is not enabled in the config, use --force to enable it"); + process.exit(1); + } + + const token = options.token || process.env.BEARER_TOKEN; + const server = getSystemMcp(app); + + if (verbose) { + console.info( + `\nāš™ļø Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`, + ); + console.info( + `šŸ“š Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`, + ); + console.info("\nMCP server is running on STDIO transport"); + } + + if (options.logLevel) { + server.setLogLevel(options.logLevel as any); + } + + const stdout = process.stdout; + const stdin = process.stdin; + const stderr = process.stderr; + + { + using transport = stdioTransport(server, { + stdin, + stdout, + stderr, + raw: new Request("https://localhost", { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }), + }); + } +} diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index e7e23b7..d85dab0 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -19,11 +19,15 @@ export const user: CliCommand = (program) => { .addArgument( new Argument("", "action to perform").choices(["create", "update", "token"]), ) + .option("--config ", "config file") + .option("--db-url ", "database url, can be any valid sqlite url") .action(action); }; async function action(action: "create" | "update" | "token", options: any) { const app = await makeAppFromEnv({ + config: options.config, + dbUrl: options.dbUrl, server: "node", }); @@ -84,9 +88,6 @@ async function create(app: App, options: any) { async function update(app: App, options: any) { const config = app.module.auth.toJSON(true); - const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; - const users_entity = config.entity_name as "users"; - const em = app.modules.ctx().em; const email = (await $text({ message: "Which user? Enter email", @@ -99,7 +100,10 @@ async function update(app: App, options: any) { })) as string; if ($isCancel(email)) process.exit(1); - const { data: user } = await em.repository(users_entity).findOne({ email }); + const { data: user } = await app.modules + .ctx() + .em.repository(config.entity_name as "users") + .findOne({ email }); if (!user) { $log.error("User not found"); process.exit(1); @@ -117,26 +121,10 @@ async function update(app: App, options: any) { }); if ($isCancel(password)) process.exit(1); - try { - function togglePw(visible: boolean) { - const field = em.entity(users_entity).field("strategy_value")!; - - field.config.hidden = !visible; - field.config.fillable = visible; - } - togglePw(true); - await app.modules - .ctx() - .em.mutator(users_entity) - .updateOne(user.id, { - strategy_value: await strategy.hash(password as string), - }); - togglePw(false); - + if (await app.module.auth.changePassword(user.id, password)) { $log.success(`Updated user: ${c.cyan(user.email)}`); - } catch (e) { + } else { $log.error("Error updating user"); - $console.error(e); } } diff --git a/app/src/cli/utils/sys.ts b/app/src/cli/utils/sys.ts index e7f07c6..e1fd340 100644 --- a/app/src/cli/utils/sys.ts +++ b/app/src/cli/utils/sys.ts @@ -26,7 +26,7 @@ export async function getVersion(_path: string = "") { return JSON.parse(pkg).version ?? "preview"; } } catch (e) { - console.error("Failed to resolve version"); + //console.error("Failed to resolve version"); } return "unknown"; diff --git a/app/src/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index c22b811..cf40bcf 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -27,7 +27,7 @@ export class SchemaObject { ) { this._default = deepFreeze(_schema.template({}, { withOptional: true }) as any); this._value = deepFreeze( - parse(_schema, structuredClone(initial ?? {}), { + parse(_schema, initial ?? {}, { withDefaults: true, //withExtendedDefaults: true, forceParse: this.isForceParse(), @@ -177,7 +177,6 @@ export class SchemaObject { this.throwIfRestricted(partial); - // overwrite arrays and primitives, only deep merge objects // @ts-ignore const config = set(current, path, value); diff --git a/app/src/core/test/utils.ts b/app/src/core/test/utils.ts index d4cefa9..c7971e2 100644 --- a/app/src/core/test/utils.ts +++ b/app/src/core/test/utils.ts @@ -1,12 +1,37 @@ -import { createApp as createAppInternal, type CreateAppConfig } from "App"; -import { bunSqlite } from "adapter/bun/connection/BunSqliteConnection"; -import { Connection } from "data/connection/Connection"; +import { Connection, createApp as createAppInternal, type CreateAppConfig } from "bknd"; +import { bunSqlite } from "bknd/adapter/bun"; +import type { McpServer } from "bknd/utils"; -export { App } from "App"; +export { App } from "bknd"; export function createApp({ connection, ...config }: CreateAppConfig = {}) { return createAppInternal({ ...config, - connection: Connection.isConnection(connection) ? connection : bunSqlite(connection as any), + connection: Connection.isConnection(connection) + ? connection + : (bunSqlite(connection as any) as any), }); } + +export function createMcpToolCaller() { + return async (server: McpServer, name: string, args: any, raw?: any) => { + const res = await server.handle( + { + jsonrpc: "2.0", + method: "tools/call", + params: { + name, + arguments: args, + }, + }, + raw, + ); + + if ((res.result as any)?.isError) { + console.dir(res.result, { depth: null }); + throw new Error((res.result as any)?.content?.[0]?.text ?? "Unknown error"); + } + + return JSON.parse((res.result as any)?.content?.[0]?.text ?? "null"); + }; +} diff --git a/app/src/core/utils/console.ts b/app/src/core/utils/console.ts index b07fa2c..4932ada 100644 --- a/app/src/core/utils/console.ts +++ b/app/src/core/utils/console.ts @@ -76,6 +76,7 @@ declare global { | { level: TConsoleSeverity; id?: string; + enabled?: boolean; } | undefined; } @@ -86,6 +87,7 @@ const defaultLevel = env("cli_log_level", "log") as TConsoleSeverity; // biome-ignore lint/suspicious/noAssignInExpressions: const config = (globalThis.__consoleConfig ??= { level: defaultLevel, + enabled: true, //id: crypto.randomUUID(), // for debugging }); @@ -95,6 +97,14 @@ export const $console = new Proxy(config as any, { switch (prop) { case "original": return console; + case "disable": + return () => { + config.enabled = false; + }; + case "enable": + return () => { + config.enabled = true; + }; case "setLevel": return (l: TConsoleSeverity) => { config.level = l; @@ -105,6 +115,10 @@ export const $console = new Proxy(config as any, { }; } + if (!config.enabled) { + return () => null; + } + const current = keys.indexOf(config.level); const requested = keys.indexOf(prop as string); @@ -118,6 +132,8 @@ export const $console = new Proxy(config as any, { } & { setLevel: (l: TConsoleSeverity) => void; resetLevel: () => void; + disable: () => void; + enable: () => void; }; export function colorizeConsole(con: typeof console) { diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts index ea5eb2b..8e812cf 100644 --- a/app/src/core/utils/file.ts +++ b/app/src/core/utils/file.ts @@ -1,7 +1,7 @@ import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; -import { randomString } from "core/utils/strings"; +import { randomString } from "./strings"; import type { Context } from "hono"; -import { invariant } from "core/utils/runtime"; +import { invariant } from "./runtime"; import { $console } from "./console"; export function getContentName(request: Request): string | undefined; diff --git a/app/src/core/utils/index.ts b/app/src/core/utils/index.ts index 36928c5..163a148 100644 --- a/app/src/core/utils/index.ts +++ b/app/src/core/utils/index.ts @@ -13,18 +13,5 @@ export * from "./uuid"; export * from "./test"; export * from "./runtime"; export * from "./numbers"; -export { - s, - stripMark, - mark, - stringIdentifier, - SecretSchema, - secret, - parse, - jsc, - describeRoute, - schemaToSpec, - openAPISpecs, - type ParseOptions, - InvalidSchemaError, -} from "./schema"; +export * from "./schema"; +export { DebugLogger } from "./DebugLogger"; diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 2bf1e60..4a5e129 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -26,6 +26,20 @@ export function omitKeys( return result; } +export function pickKeys( + obj: T, + keys_: readonly K[], +): Pick> { + const keys = new Set(keys_); + const result = {} as Pick>; + for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { + if (keys.has(key as K)) { + (result as any)[key] = value; + } + } + return result; +} + export function safelyParseObjectValues(obj: T): T { return Object.entries(obj).reduce((acc, [key, value]) => { try { @@ -189,6 +203,30 @@ export function objectDepth(object: object): number { return level; } +export function limitObjectDepth(obj: T, maxDepth: number): T { + function _limit(current: any, depth: number): any { + if (isPlainObject(current)) { + if (depth > maxDepth) { + return undefined; + } + const result: any = {}; + for (const key in current) { + if (Object.prototype.hasOwnProperty.call(current, key)) { + result[key] = _limit(current[key], depth + 1); + } + } + return result; + } + if (Array.isArray(current)) { + // Arrays themselves are not limited, but their object elements are + return current.map((item) => _limit(item, depth)); + } + // Primitives are always returned, regardless of depth + return current; + } + return _limit(obj, 1); +} + export function objectCleanEmpty(obj: Obj): Obj { if (!obj) return obj; return Object.entries(obj).reduce((acc, [key, value]) => { diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index 0382700..5f10092 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -1,7 +1,21 @@ import * as s from "jsonv-ts"; export { validator as jsc, type Options } from "jsonv-ts/hono"; -export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono"; +export { describeRoute, schemaToSpec, openAPISpecs, info } from "jsonv-ts/hono"; +export { + mcp, + McpServer, + Resource, + Tool, + mcpTool, + mcpResource, + getMcpServer, + stdioTransport, + McpClient, + type McpClientConfig, + type ToolAnnotation, + type ToolHandlerCtx, +} from "jsonv-ts/mcp"; export { secret, SecretSchema } from "./secret"; diff --git a/app/src/core/utils/schema/secret.ts b/app/src/core/utils/schema/secret.ts index 7eae592..6fcdf14 100644 --- a/app/src/core/utils/schema/secret.ts +++ b/app/src/core/utils/schema/secret.ts @@ -1,6 +1,7 @@ -import { StringSchema, type IStringOptions } from "jsonv-ts"; +import type { s } from "bknd/utils"; +import { StringSchema } from "jsonv-ts"; -export class SecretSchema extends StringSchema {} +export class SecretSchema extends StringSchema {} -export const secret = (o?: O): SecretSchema & O => +export const secret = (o?: O): SecretSchema & O => new SecretSchema(o) as any; diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 13dbcc3..fbe7514 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -1,5 +1,4 @@ import { transformObject } from "bknd/utils"; - import { Module } from "modules/Module"; import { DataController } from "./api/DataController"; import { type AppDataConfig, dataConfigSchema } from "./data-schema"; @@ -49,10 +48,9 @@ export class AppData extends Module { this.ctx.em.addIndex(index); } - this.ctx.server.route( - this.basepath, - new DataController(this.ctx, this.config).getController(), - ); + const dataController = new DataController(this.ctx, this.config); + dataController.registerMcp(); + this.ctx.server.route(this.basepath, dataController.getController()); this.ctx.guard.registerPermissions(Object.values(DataPermissions)); this.setBuilt(); diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index b468a08..ba79df7 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -1,7 +1,7 @@ import type { Handler } from "hono/types"; import type { ModuleBuildContext } from "modules"; import { Controller } from "modules/Controller"; -import { jsc, s, describeRoute, schemaToSpec, omitKeys } from "bknd/utils"; +import { jsc, s, describeRoute, schemaToSpec, omitKeys, pickKeys, mcpTool } from "bknd/utils"; import * as SystemPermissions from "modules/permissions"; import type { AppDataConfig } from "../data-schema"; import type { EntityManager, EntityData } from "data/entities"; @@ -62,6 +62,11 @@ export class DataController extends Controller { hono.get( "/sync", permission(DataPermissions.databaseSync), + mcpTool("data_sync", { + annotations: { + destructiveHint: true, + }, + }), describeRoute({ summary: "Sync database schema", tags: ["data"], @@ -77,9 +82,7 @@ export class DataController extends Controller { ), async (c) => { const { force, drop } = c.req.valid("query"); - //console.log("force", force); const tables = await this.em.schema().introspect(); - //console.log("tables", tables); const changes = await this.em.schema().sync({ force, drop, @@ -165,6 +168,7 @@ export class DataController extends Controller { summary: "Retrieve entity info", tags: ["data"], }), + mcpTool("data_entity_info"), jsc("param", s.object({ entity: entitiesEnum })), async (c) => { const { entity } = c.req.param(); @@ -201,7 +205,9 @@ export class DataController extends Controller { const entitiesEnum = this.getEntitiesEnum(this.em); // @todo: make dynamic based on entity - const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as number | string }); + const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })], { + coerce: (v) => v as number | string, + }); /** * Function endpoints @@ -214,6 +220,7 @@ export class DataController extends Controller { summary: "Count entities", tags: ["data"], }), + mcpTool("data_entity_fn_count"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery.properties.where), async (c) => { @@ -236,6 +243,7 @@ export class DataController extends Controller { summary: "Check if entity exists", tags: ["data"], }), + mcpTool("data_entity_fn_exists"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery.properties.where), async (c) => { @@ -268,6 +276,9 @@ export class DataController extends Controller { (p) => pick.includes(p.name), ) as any), ]; + const saveRepoQuerySchema = (pick: string[] = Object.keys(saveRepoQuery.properties)) => { + return s.object(pickKeys(saveRepoQuery.properties, pick as any)); + }; hono.get( "/:entity", @@ -300,6 +311,12 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityRead), + mcpTool("data_entity_read_one", { + inputSchema: { + param: s.object({ entity: entitiesEnum, id: idType }), + query: saveRepoQuerySchema(["offset", "sort", "select"]), + }, + }), jsc( "param", s.object({ @@ -375,6 +392,12 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityRead), + mcpTool("data_entity_read_many", { + inputSchema: { + param: s.object({ entity: entitiesEnum }), + json: fnQuery, + }, + }), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery, { skipOpenAPI: true }), async (c) => { @@ -400,6 +423,7 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityCreate), + mcpTool("data_entity_insert"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), async (c) => { @@ -427,6 +451,15 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityUpdate), + 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", @@ -458,6 +491,7 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityUpdate), + mcpTool("data_entity_update_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("json", s.object({})), async (c) => { @@ -480,6 +514,7 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityDelete), + mcpTool("data_entity_delete_one"), jsc("param", s.object({ entity: entitiesEnum, id: idType })), async (c) => { const { entity, id } = c.req.valid("param"); @@ -500,6 +535,12 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityDelete), + 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) => { @@ -516,4 +557,35 @@ export class DataController extends Controller { return hono; } + + override registerMcp() { + this.ctx.mcp + .resource( + "data_entities", + "bknd://data/entities", + (c) => c.json(c.context.ctx().em.toJSON().entities), + { + title: "Entities", + description: "Retrieve all entities", + }, + ) + .resource( + "data_relations", + "bknd://data/relations", + (c) => c.json(c.context.ctx().em.toJSON().relations), + { + title: "Relations", + description: "Retrieve all relations", + }, + ) + .resource( + "data_indices", + "bknd://data/indices", + (c) => c.json(c.context.ctx().em.toJSON().indices), + { + title: "Indices", + description: "Retrieve all indices", + }, + ); + } } diff --git a/app/src/data/connection/index.ts b/app/src/data/connection/index.ts index a55d135..969611a 100644 --- a/app/src/data/connection/index.ts +++ b/app/src/data/connection/index.ts @@ -9,6 +9,7 @@ export { type ConnQueryResults, customIntrospector, } from "./Connection"; +export { DummyConnection } from "./DummyConnection"; // sqlite export { SqliteConnection } from "./sqlite/SqliteConnection"; diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index b1750be..f416da6 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -3,14 +3,16 @@ 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"; export const FIELDS = { ...FieldClassMap, ...RelationFieldClassMap, media: { schema: mediaFieldConfigSchema, field: MediaField }, }; +export const FIELD_TYPES = Object.keys(FIELDS); export type FieldType = keyof typeof FIELDS; export const RELATIONS = RelationClassMap; @@ -28,17 +30,30 @@ export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => { ); }); export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject)); -export const entityFields = s.record(fieldsSchema); +export const entityFields = s.record(fieldsSchema, { default: {} }); export type TAppDataField = s.Static; export type TAppDataEntityFields = s.Static; export const entitiesSchema = s.strictObject({ name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI) - type: s.string({ enum: entityTypes, default: "regular" }), - config: entityConfigSchema, - fields: entityFields, + type: s.string({ enum: entityTypes, default: "regular" }).optional(), + config: entityConfigSchema.optional(), + 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( @@ -61,12 +76,27 @@ export const indicesSchema = s.strictObject({ unique: s.boolean({ default: false }).optional(), }); -export const dataConfigSchema = s.strictObject({ +export const dataConfigSchema = $object("config_data", { basepath: s.string({ default: "/api/data" }).optional(), default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(), - entities: s.record(entitiesSchema, { default: {} }).optional(), - relations: s.record(s.anyOf(relationsSchema), { default: {} }).optional(), - indices: s.record(indicesSchema, { default: {} }).optional(), -}); + entities: $record("config_data_entities", entitiesSchema, { default: {} }).optional(), + relations: $record( + "config_data_relations", + s.anyOf(relationsSchema), + { + default: {}, + }, + s.strictObject({ + type: s.string({ enum: Object.keys(RelationClassMap) }), + source: s.string(), + target: s.string(), + config: s.object({}).optional(), + }), + ).optional(), + indices: $record("config_data_indices", indicesSchema, { + default: {}, + mcp: { update: false }, + }).optional(), +}).strict(); export type AppDataConfig = s.Static; diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index 10612b5..fcbe092 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -10,14 +10,17 @@ import { // @todo: entity must be migrated to typebox export const entityConfigSchema = s - .strictObject({ - name: s.string(), - name_singular: s.string(), - description: s.string(), - sort_field: s.string({ default: config.data.default_primary_field }), - sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), - primary_format: s.string({ enum: primaryFieldTypes }), - }) + .strictObject( + { + name: s.string(), + name_singular: s.string(), + description: s.string(), + sort_field: s.string({ default: config.data.default_primary_field }), + sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), + primary_format: s.string({ enum: primaryFieldTypes }), + }, + { default: {} }, + ) .partial(); export type EntityConfig = s.Static; diff --git a/app/src/data/server/query.spec.ts b/app/src/data/server/query.spec.ts index 3992599..89585a3 100644 --- a/app/src/data/server/query.spec.ts +++ b/app/src/data/server/query.spec.ts @@ -26,9 +26,6 @@ describe("server/query", () => { expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] }); expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] }); expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] }); - - expect(() => parse({ select: "not allowed" })).toThrow(); - expect(() => parse({ select: "id," })).toThrow(); }); test("join", () => { diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts index 15ff95f..cb4defe 100644 --- a/app/src/data/server/query.ts +++ b/app/src/data/server/query.ts @@ -1,12 +1,11 @@ import { s, isObject, $console } from "bknd/utils"; import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder"; -import type { anyOf, CoercionOptions, Schema } from "jsonv-ts"; // ------- // helpers const stringIdentifier = s.string({ // allow "id", "id,title" – but not "id," or "not allowed" - pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$", + //pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$", }); const stringArray = s.anyOf( [ @@ -24,7 +23,7 @@ const stringArray = s.anyOf( if (v.includes(",")) { return v.split(","); } - return [v]; + return [v].filter(Boolean); } return []; }, @@ -79,6 +78,8 @@ const where = s.anyOf([s.string(), s.object({})], { }, ], coerce: (value: unknown) => { + if (value === undefined || value === null || value === "") return {}; + const q = typeof value === "string" ? JSON.parse(value) : value; return WhereBuilder.convert(q); }, @@ -96,9 +97,9 @@ export type RepoWithSchema = Record< } >; -const withSchema = (self: Schema): Schema<{}, Type, Type> => +const withSchema = (self: s.Schema): s.Schema<{}, Type, Type> => s.anyOf([stringIdentifier, s.array(stringIdentifier), self], { - coerce: function (this: typeof anyOf, _value: unknown, opts: CoercionOptions = {}) { + coerce: function (this: typeof s.anyOf, _value: unknown, opts: s.CoercionOptions = {}) { let value: any = _value; if (typeof value === "string") { diff --git a/app/src/index.ts b/app/src/index.ts index 28ebbe9..bd6515f 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -35,6 +35,7 @@ export type { BkndConfig } from "bknd/adapter"; export * as middlewares from "modules/middlewares"; export { registries } from "modules/registries"; +export { getSystemMcp } from "modules/mcp/system-mcp"; /** * Core @@ -131,6 +132,7 @@ export { BaseIntrospector, Connection, customIntrospector, + DummyConnection, type FieldSpec, type IndexSpec, type DbFunctions, diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index a287d0a..4e71d83 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -1,6 +1,7 @@ import { MediaAdapters } from "media/media-registry"; import { registries } from "modules/registries"; import { s, objectTransform } from "bknd/utils"; +import { $object, $record, $schema } from "modules/mcp"; export const ADAPTERS = { ...MediaAdapters, @@ -22,7 +23,8 @@ export function buildMediaSchema() { ); }); - return s.strictObject( + return $object( + "config_media", { enabled: s.boolean({ default: false }), basepath: s.string({ default: "/api/media" }), @@ -37,7 +39,11 @@ export function buildMediaSchema() { }, { default: {} }, ), - adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(), + // @todo: currently cannot be updated partially using mcp + adapter: $schema( + "config_media_adapter", + s.anyOf(Object.values(adapterSchemaObject)), + ).optional(), }, { default: {}, diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index 51ae026..db9f3d8 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -1,4 +1,4 @@ -import type { App, SafeUser } from "bknd"; +import type { App, Permission, SafeUser } from "bknd"; import { type Context, type Env, Hono } from "hono"; import * as middlewares from "modules/middlewares"; import type { EntityManager } from "data/entities"; @@ -19,20 +19,6 @@ export interface ServerEnv extends Env { [key: string]: any; } -/* export type ServerEnv = Env & { - Variables: { - app: App; - // to prevent resolving auth multiple times - auth?: { - resolved: boolean; - registered: boolean; - skip: boolean; - user?: SafeUser; - }; - html?: string; - }; -}; */ - export class Controller { protected middlewares = middlewares; @@ -65,7 +51,8 @@ export class Controller { protected getEntitiesEnum(em: EntityManager): s.StringSchema { const entities = em.entities.map((e) => e.name); - // @todo: current workaround to allow strings (sometimes building is not fast enough to get the entities) - return entities.length > 0 ? s.anyOf([s.string({ enum: entities }), s.string()]) : s.string(); + return entities.length > 0 ? s.string({ enum: entities }) : s.string(); } + + registerMcp(): void {} } diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index 126a15e..f402e04 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -1,3 +1,4 @@ +import type { App } from "bknd"; import type { EventManager } from "core/events"; import type { Connection } from "data/connection"; import type { EntityManager } from "data/entities"; @@ -5,11 +6,15 @@ import type { Hono } from "hono"; import type { ServerEnv } from "modules/Controller"; import type { ModuleHelper } from "./ModuleHelper"; import { SchemaObject } from "core/object/SchemaObject"; -import type { DebugLogger } from "core/utils/DebugLogger"; import type { Guard } from "auth/authorize/Guard"; +import type { McpServer, DebugLogger } from "bknd/utils"; type PartialRec = { [P in keyof T]?: PartialRec }; +export type ModuleBuildContextMcpContext = { + app: App; + ctx: () => ModuleBuildContext; +}; export type ModuleBuildContext = { connection: Connection; server: Hono; @@ -19,6 +24,7 @@ export type ModuleBuildContext = { logger: DebugLogger; flags: (typeof Module)["ctx_flags"]; helper: ModuleHelper; + mcp: McpServer; }; export abstract class Module { diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index 5bfd30d..60a6dfc 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -3,8 +3,11 @@ import { Entity } from "data/entities"; import type { EntityIndex, Field } from "data/fields"; import { entityTypes } from "data/entities/Entity"; import { isEqual } from "lodash-es"; -import type { ModuleBuildContext } from "./Module"; +import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module"; import type { EntityRelation } from "data/relations"; +import type { Permission } from "core/security/Permission"; +import { Exception } from "core/errors"; +import { invariant, isPlainObject } from "bknd/utils"; export class ModuleHelper { constructor(protected ctx: Omit) {} @@ -110,4 +113,26 @@ export class ModuleHelper { entity.__replaceField(name, newField); } + + async throwUnlessGranted( + permission: Permission | string, + c: { context: ModuleBuildContextMcpContext; raw?: unknown }, + ) { + invariant(c.context.app, "app is not available in mcp context"); + const auth = c.context.app.module.auth; + if (!auth.enabled) return; + + if (c.raw === undefined || c.raw === null) { + throw new Exception("Request/Headers/Context is not available in mcp context", 400); + } + + const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any); + + if (!this.ctx.guard.granted(permission, user)) { + throw new Exception( + `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, + 403, + ); + } + } } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 42d9a94..660902a 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,8 +1,16 @@ -import { mark, stripMark, $console, s, objectEach, transformObject } from "bknd/utils"; +import { + mark, + stripMark, + $console, + s, + objectEach, + transformObject, + McpServer, + DebugLogger, +} from "bknd/utils"; import { Guard } from "auth/authorize/Guard"; import { env } from "core/env"; import { BkndError } from "core/errors"; -import { DebugLogger } from "core/utils/DebugLogger"; import { EventManager, Event } from "core/events"; import * as $diff from "core/object/diff"; import type { Connection } from "data/connection"; @@ -144,6 +152,7 @@ export class ModuleManager { server!: Hono; emgr!: EventManager; guard!: Guard; + mcp!: ModuleBuildContext["mcp"]; private _version: number = 0; private _built = false; @@ -271,6 +280,14 @@ export class ModuleManager { ? this.em.clear() : new EntityManager([], this.connection, [], [], this.emgr); this.guard = new Guard(); + this.mcp = new McpServer(undefined as any, { + app: new Proxy(this, { + get: () => { + throw new Error("app is not available in mcp context"); + }, + }) as any, + ctx: () => this.ctx(), + }); } const ctx = { @@ -281,6 +298,7 @@ export class ModuleManager { guard: this.guard, flags: Module.ctx_flags, logger: this.logger, + mcp: this.mcp, }; return { @@ -702,7 +720,7 @@ export class ModuleManager { return { version: this.version(), ...schemas, - }; + } as { version: number } & ModuleSchemas; } toJSON(secrets?: boolean): { version: number } & ModuleConfigs { diff --git a/app/src/modules/mcp/$object.ts b/app/src/modules/mcp/$object.ts new file mode 100644 index 0000000..a57257b --- /dev/null +++ b/app/src/modules/mcp/$object.ts @@ -0,0 +1,137 @@ +import { Tool, getPath, limitObjectDepth, s } from "bknd/utils"; +import { + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type McpSchema, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; +import type { Module } from "modules/Module"; + +export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {} + +export class ObjectToolSchema< + const P extends s.TProperties = s.TProperties, + const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions, + > + extends s.ObjectSchema + implements McpSchema +{ + constructor(name: string, properties: P, options?: ObjectToolSchemaOptions) { + const { mcp, ...rest } = options || {}; + + super(properties, rest as any); + this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {}); + } + + get mcp(): McpSchemaHelper { + return this[mcpSchemaSymbol]; + } + + private toolGet(node: s.Node) { + return new Tool( + [this.mcp.name, "get"].join("_"), + { + ...this.mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + path: s + .string({ + pattern: /^[a-zA-Z0-9_.]{0,}$/, + title: "Path", + description: "Path to the property to get, e.g. `key.subkey`", + }) + .optional(), + depth: s + .number({ + description: "Limit the depth of the response", + }) + .optional(), + secrets: s + .boolean({ + default: false, + description: "Include secrets in the response config", + }) + .optional(), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.secrets); + const config = getPath(configs, node.instancePath); + let value = getPath(config, params.path ?? []); + + if (params.depth) { + value = limitObjectDepth(value, params.depth); + } + + return ctx.json({ + path: params.path ?? "", + secrets: params.secrets ?? false, + partial: !!params.depth, + value: value ?? null, + }); + }, + ); + } + + private toolUpdate(node: s.Node) { + const schema = this.mcp.cleanSchema; + return new Tool( + [this.mcp.name, "update"].join("_"), + { + ...this.mcp.getToolOptions("update"), + inputSchema: s.strictObject({ + full: s.boolean({ default: false }).optional(), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), + value: s.strictObject(schema.properties as {}).partial(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const { full, value, return_config } = params; + const [module_name] = node.instancePath; + + if (full) { + await ctx.context.app.mutateConfig(module_name as any).set(value); + } else { + await ctx.context.app.mutateConfig(module_name as any).patch("", value); + } + + let config: any = undefined; + if (return_config) { + const configs = ctx.context.app.toJSON(); + config = getPath(configs, node.instancePath); + } + + return ctx.json({ + success: true, + module: module_name, + config, + }); + }, + ); + } + + getTools(node: s.Node): Tool[] { + const { tools = [] } = this.mcp.options; + return [this.toolGet(node), this.toolUpdate(node), ...tools]; + } +} + +export const $object = < + const P extends s.TProperties = s.TProperties, + const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions, +>( + name: string, + properties: P, + options?: s.StrictOptions, +): ObjectToolSchema & O => { + return new ObjectToolSchema(name, properties, options) as any; +}; diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts new file mode 100644 index 0000000..a10054a --- /dev/null +++ b/app/src/modules/mcp/$record.ts @@ -0,0 +1,265 @@ +import { getPath, s, Tool } from "bknd/utils"; +import { + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type McpSchema, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; + +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"); + +export class RecordToolSchema< + AP extends s.Schema, + O extends RecordToolSchemaOptions = RecordToolSchemaOptions, + > + extends s.RecordSchema + implements McpSchema +{ + constructor(name: string, ap: AP, options?: RecordToolSchemaOptions, new_schema?: s.Schema) { + const { mcp, ...rest } = options || {}; + super(ap, rest as any); + + this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {}); + this[opts] = { + new_schema, + }; + } + + get mcp(): McpSchemaHelper { + return this[mcpSchemaSymbol]; + } + + private getNewSchema(fallback: s.Schema = this.additionalProperties) { + return this[opts].new_schema ?? this.additionalProperties ?? fallback; + } + + private toolGet(node: s.Node>) { + return new Tool( + [this.mcp.name, "get"].join("_"), + { + ...this.mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + key: s + .string({ + description: "key to get", + }) + .optional(), + secrets: s + .boolean({ + default: false, + description: "(optional) include secrets in the response config", + }) + .optional(), + schema: s + .boolean({ + default: false, + description: "(optional) include the schema in the response", + }) + .optional(), + }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, + }, + 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)) { + throw new Error(`Key "${params.key}" not found in config`); + } + 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, + }); + }, + ); + } + + private toolAdd(node: s.Node>) { + return new Tool( + [this.mcp.name, "add"].join("_"), + { + ...this.mcp.getToolOptions("add"), + inputSchema: s.strictObject({ + key: s.string({ + 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) => { + const configs = ctx.context.app.toJSON(true); + 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); + + const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath); + + return ctx.json({ + success: true, + module: module_name, + action: { + type: "add", + key: params.key, + }, + config: params.return_config ? newConfig : undefined, + }); + }, + ); + } + + private toolUpdate(node: s.Node>) { + return new Tool( + [this.mcp.name, "update"].join("_"), + { + ...this.mcp.getToolOptions("update"), + inputSchema: s.strictObject({ + key: s.string({ + description: "key to update", + }), + value: this.mcp.getCleanSchema(this.getNewSchema(s.object({}))), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(true); + 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) + .patch([...rest, params.key], params.value); + + const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath); + + return ctx.json({ + success: true, + module: module_name, + action: { + type: "update", + key: params.key, + }, + config: params.return_config ? newConfig : undefined, + }); + }, + ); + } + + private toolRemove(node: s.Node>) { + return new Tool( + [this.mcp.name, "remove"].join("_"), + { + ...this.mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + 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) => { + const configs = ctx.context.app.toJSON(true); + 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(".")); + + const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath); + + return ctx.json({ + success: true, + module: module_name, + action: { + type: "remove", + key: params.key, + }, + config: params.return_config ? newConfig : undefined, + }); + }, + ); + } + + getTools(node: s.Node>): Tool[] { + const { tools = [], get = true, add = true, update = true, remove = true } = this.mcp.options; + + return [ + get && this.toolGet(node), + add && this.toolAdd(node), + update && this.toolUpdate(node), + remove && this.toolRemove(node), + ...tools, + ].filter(Boolean) as Tool[]; + } +} + +export const $record = ( + name: string, + ap: AP, + options?: s.StrictOptions, + new_schema?: s.Schema, +): RecordToolSchema => new RecordToolSchema(name, ap, options, new_schema) as any; diff --git a/app/src/modules/mcp/$schema.ts b/app/src/modules/mcp/$schema.ts new file mode 100644 index 0000000..9c86d4a --- /dev/null +++ b/app/src/modules/mcp/$schema.ts @@ -0,0 +1,88 @@ +import { Tool, getPath, s } from "bknd/utils"; +import { + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; + +export interface SchemaToolSchemaOptions extends s.ISchemaOptions, SchemaWithMcpOptions {} + +export const $schema = < + const S extends s.Schema, + const O extends SchemaToolSchemaOptions = SchemaToolSchemaOptions, +>( + name: string, + schema: S, + options?: O, +): S => { + const mcp = new McpSchemaHelper(schema, name, options || {}); + + const toolGet = (node: s.Node) => { + return new Tool( + [mcp.name, "get"].join("_"), + { + ...mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + secrets: s + .boolean({ + default: false, + description: "Include secrets in the response config", + }) + .optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.secrets); + const value = getPath(configs, node.instancePath); + + return ctx.json({ + secrets: params.secrets ?? false, + value: value ?? null, + }); + }, + ); + }; + + const toolUpdate = (node: s.Node) => { + return new Tool( + [mcp.name, "update"].join("_"), + { + ...mcp.getToolOptions("update"), + inputSchema: s.strictObject({ + value: schema as any, + return_config: s.boolean({ default: false }).optional(), + secrets: s.boolean({ default: false }).optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const { value, return_config, secrets } = params; + const [module_name, ...rest] = node.instancePath; + + await ctx.context.app.mutateConfig(module_name as any).overwrite(rest, value); + + let config: any = undefined; + if (return_config) { + const configs = ctx.context.app.toJSON(secrets); + config = getPath(configs, node.instancePath); + } + + return ctx.json({ + success: true, + module: module_name, + config, + }); + }, + ); + }; + + const getTools = (node: s.Node) => { + const { tools = [] } = mcp.options; + return [toolGet(node), toolUpdate(node), ...tools]; + }; + + return Object.assign(schema, { + [mcpSchemaSymbol]: mcp, + getTools, + }); +}; diff --git a/app/src/modules/mcp/McpSchemaHelper.ts b/app/src/modules/mcp/McpSchemaHelper.ts new file mode 100644 index 0000000..686e7ff --- /dev/null +++ b/app/src/modules/mcp/McpSchemaHelper.ts @@ -0,0 +1,77 @@ +import type { App } from "bknd"; +import { + type Tool, + type ToolAnnotation, + type Resource, + type ToolHandlerCtx, + s, + isPlainObject, + autoFormatString, +} from "bknd/utils"; +import type { ModuleBuildContext } from "modules"; +import { excludePropertyTypes, rescursiveClean } from "./utils"; + +export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema"); + +export interface McpToolOptions { + title?: string; + description?: string; + annotations?: ToolAnnotation; + tools?: Tool[]; + resources?: Resource[]; +} + +export type SchemaWithMcpOptions = { + mcp?: McpToolOptions & AdditionalOptions; +}; + +export type AppToolContext = { + app: App; + ctx: () => ModuleBuildContext; +}; +export type AppToolHandlerCtx = ToolHandlerCtx; + +export interface McpSchema extends s.Schema { + getTools(node: s.Node): Tool[]; +} + +export class McpSchemaHelper { + cleanSchema: s.ObjectSchema; + + constructor( + public schema: s.Schema, + public name: string, + public options: McpToolOptions & AdditionalOptions, + ) { + this.cleanSchema = this.getCleanSchema(this.schema as s.ObjectSchema); + } + + getCleanSchema(schema: s.ObjectSchema) { + if (schema.type !== "object") return schema; + + const props = excludePropertyTypes( + schema as any, + (i) => isPlainObject(i) && mcpSchemaSymbol in i, + ); + const _schema = s.strictObject(props); + return rescursiveClean(_schema, { + removeRequired: true, + removeDefault: false, + }) as s.ObjectSchema; + } + + getToolOptions(suffix?: string) { + const { tools, resources, ...rest } = this.options; + const label = (text?: string) => + text && [suffix && autoFormatString(suffix), text].filter(Boolean).join(" "); + return { + title: label(this.options.title ?? this.schema.title), + description: label(this.options.description ?? this.schema.description), + annotations: { + destructiveHint: true, + idempotentHint: true, + ...rest.annotations, + }, + }; + } +} diff --git a/app/src/modules/mcp/index.ts b/app/src/modules/mcp/index.ts new file mode 100644 index 0000000..9a19c09 --- /dev/null +++ b/app/src/modules/mcp/index.ts @@ -0,0 +1,4 @@ +export * from "./$object"; +export * from "./$record"; +export * from "./$schema"; +export * from "./McpSchemaHelper"; diff --git a/app/src/modules/mcp/system-mcp.ts b/app/src/modules/mcp/system-mcp.ts new file mode 100644 index 0000000..bd278db --- /dev/null +++ b/app/src/modules/mcp/system-mcp.ts @@ -0,0 +1,37 @@ +import type { App } from "App"; +import { mcpSchemaSymbol, type McpSchema } from "modules/mcp"; +import { getMcpServer, isObject, s, McpServer } from "bknd/utils"; +import { getVersion } from "core/env"; + +export function getSystemMcp(app: App) { + const middlewareServer = getMcpServer(app.server); + + const appConfig = app.modules.configs(); + const { version, ...appSchema } = app.getSchema(); + const schema = s.strictObject(appSchema); + const result = [...schema.walk({ maxDepth: 3 })]; + const nodes = result.filter((n) => mcpSchemaSymbol in n.schema) as s.Node[]; + const tools = [ + // tools from hono routes + ...middlewareServer.tools, + // tools added from ctx + ...app.modules.ctx().mcp.tools, + ].sort((a, b) => a.name.localeCompare(b.name)); + + // tools from app schema + tools.push( + ...nodes.flatMap((n) => n.schema.getTools(n)).sort((a, b) => a.name.localeCompare(b.name)), + ); + + const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources]; + + return new McpServer( + { + name: "bknd", + version: getVersion(), + }, + { app, ctx: () => app.modules.ctx() }, + tools, + resources, + ); +} diff --git a/app/src/modules/mcp/utils.spec.ts b/app/src/modules/mcp/utils.spec.ts new file mode 100644 index 0000000..a947ac1 --- /dev/null +++ b/app/src/modules/mcp/utils.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "bun:test"; +import { excludePropertyTypes, rescursiveClean } from "./utils"; +import { s } from "../../core/utils/schema"; + +describe("rescursiveOptional", () => { + it("should make all properties optional", () => { + const schema = s.strictObject({ + a: s.string({ default: "a" }), + b: s.number(), + nested: s.strictObject({ + c: s.string().optional(), + d: s.number(), + nested2: s.record(s.string()), + }), + }); + + //console.log(schema.toJSON()); + const result = rescursiveClean(schema, { + removeRequired: true, + removeDefault: true, + }); + const json = result.toJSON(); + + expect(json.required).toBeUndefined(); + expect(json.properties.a.default).toBeUndefined(); + expect(json.properties.nested.required).toBeUndefined(); + expect(json.properties.nested.properties.nested2.required).toBeUndefined(); + }); + + it("should exclude properties", () => { + const schema = s.strictObject({ + a: s.string(), + b: s.number(), + }); + + const result = excludePropertyTypes(schema, (instance) => instance instanceof s.StringSchema); + expect(Object.keys(result).length).toBe(1); + }); +}); diff --git a/app/src/modules/mcp/utils.ts b/app/src/modules/mcp/utils.ts new file mode 100644 index 0000000..1307ea3 --- /dev/null +++ b/app/src/modules/mcp/utils.ts @@ -0,0 +1,49 @@ +import { isPlainObject, transformObject, s } from "bknd/utils"; + +export function rescursiveClean( + input: s.Schema, + opts?: { + removeRequired?: boolean; + removeDefault?: boolean; + }, +): s.Schema { + const json = input.toJSON(); + + const removeRequired = (obj: any) => { + if (isPlainObject(obj)) { + if ("required" in obj && opts?.removeRequired) { + obj.required = undefined; + } + + if ("default" in obj && opts?.removeDefault) { + obj.default = undefined; + } + + if ("properties" in obj && isPlainObject(obj.properties)) { + for (const key in obj.properties) { + obj.properties[key] = removeRequired(obj.properties[key]); + } + } + } + + return obj; + }; + + removeRequired(json); + return s.fromSchema(json); +} + +export function excludePropertyTypes( + input: s.ObjectSchema, + props: (instance: s.Schema | unknown) => boolean, +): s.TProperties { + const properties = { ...input.properties }; + + return transformObject(properties, (value, key) => { + if (props(value)) { + return undefined; + } + + return value; + }); +} diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index cc54754..b6fbead 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -7,3 +7,4 @@ export const configReadSecrets = new Permission("system.config.read.secrets"); export const configWrite = new Permission("system.config.write"); export const schemaRead = new Permission("system.schema.read"); export const build = new Permission("system.build"); +export const mcp = new Permission("system.mcp"); diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index d714098..d15eefe 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -91,7 +91,7 @@ export class AdminController extends Controller { logout: "/api/auth/logout", }; - const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"]; + const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*", "/tools/*"]; if (isDebug()) { paths.push("/test/*"); } diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 6a2f851..31a76c5 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -1,25 +1,35 @@ import { Exception } from "core/errors"; import { isDebug } from "core/env"; import { $console, s } from "bknd/utils"; +import { $object } from "modules/mcp"; import { cors } from "hono/cors"; import { Module } from "modules/Module"; import { AuthException } from "auth/errors"; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const; -export const serverConfigSchema = s.strictObject({ - cors: s.strictObject({ - origin: s.string({ default: "*" }), - allow_methods: s.array(s.string({ enum: serverMethods }), { - default: serverMethods, - uniqueItems: true, +export const serverConfigSchema = $object( + "config_server", + { + cors: s.strictObject({ + origin: s.string({ default: "*" }), + allow_methods: s.array(s.string({ enum: serverMethods }), { + default: serverMethods, + uniqueItems: true, + }), + allow_headers: s.array(s.string(), { + default: ["Content-Type", "Content-Length", "Authorization", "Accept"], + }), + allow_credentials: s.boolean({ default: true }), }), - allow_headers: s.array(s.string(), { - default: ["Content-Type", "Content-Length", "Authorization", "Accept"], + mcp: s.strictObject({ + enabled: s.boolean({ default: false }), }), - allow_credentials: s.boolean({ default: true }), - }), -}); + }, + { + description: "Server configuration", + }, +); export type AppServerConfig = s.Static; diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 704da55..15914ae 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -8,15 +8,18 @@ import { getTimezoneOffset, $console, getRuntimeKey, - SecretSchema, jsc, s, describeRoute, InvalidSchemaError, + openAPISpecs, + mcpTool, + mcp as mcpMiddleware, + isNode, + type McpServer, } from "bknd/utils"; import type { Context, Hono } from "hono"; import { Controller } from "modules/Controller"; -import { openAPISpecs } from "jsonv-ts/hono"; import { swaggerUI } from "@hono/swagger-ui"; import { MODULE_NAMES, @@ -26,6 +29,8 @@ import { } from "modules/ModuleManager"; import * as SystemPermissions from "modules/permissions"; import { getVersion } from "core/env"; +import type { Module } from "modules/Module"; +import { getSystemMcp } from "modules/mcp/system-mcp"; export type ConfigUpdate = { success: true; @@ -43,6 +48,8 @@ export type SchemaResponse = { }; export class SystemController extends Controller { + _mcpServer: McpServer | null = null; + constructor(private readonly app: App) { super(); } @@ -51,6 +58,53 @@ export class SystemController extends Controller { return this.app.modules.ctx(); } + register(app: App) { + app.server.route("/api/system", this.getController()); + + if (!this.app.modules.get("server").config.mcp.enabled) { + return; + } + + this.registerMcp(); + + this._mcpServer = getSystemMcp(app); + this._mcpServer.onNotification((message) => { + if (message.method === "notification/message") { + const consoleMap = { + emergency: "error", + alert: "error", + critical: "error", + error: "error", + warning: "warn", + notice: "log", + info: "info", + debug: "debug", + }; + + const level = consoleMap[message.params.level]; + if (!level) return; + + $console[level](message.params.message); + } + }); + + app.server.use( + mcpMiddleware({ + server: this._mcpServer, + sessionsEnabled: true, + debug: { + logLevel: "debug", + explainEndpoint: true, + }, + endpoint: { + path: "/mcp", + // @ts-ignore + _init: isNode() ? { duplex: "half" } : {}, + }, + }), + ); + } + private registerConfigController(client: Hono): void { const { permission } = this.middlewares; // don't add auth again, it's already added in getController @@ -77,6 +131,11 @@ export class SystemController extends Controller { summary: "Get the config for a module", tags: ["system"], }), + mcpTool("system_config", { + annotations: { + readOnlyHint: true, + }, + }), // @todo: ":module" gets not removed jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })), jsc("query", s.object({ secrets: s.boolean().optional() })), async (c) => { @@ -283,6 +342,7 @@ export class SystemController extends Controller { summary: "Build the app", tags: ["system"], }), + mcpTool("system_build"), jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })), async (c) => { const options = c.req.valid("query") as Record; @@ -298,6 +358,7 @@ export class SystemController extends Controller { hono.get( "/ping", + mcpTool("system_ping"), describeRoute({ summary: "Ping the server", tags: ["system"], @@ -307,13 +368,17 @@ export class SystemController extends Controller { hono.get( "/info", + mcpTool("system_info"), describeRoute({ summary: "Get the server info", tags: ["system"], }), (c) => c.json({ - version: c.get("app")?.version(), + version: { + config: c.get("app")?.version(), + bknd: getVersion(), + }, runtime: getRuntimeKey(), connection: { name: this.app.em.connection.name, @@ -328,19 +393,6 @@ export class SystemController extends Controller { }, origin: new URL(c.req.raw.url).origin, plugins: Array.from(this.app.plugins.keys()), - walk: { - auth: [ - ...c - .get("app") - .getSchema() - .auth.walk({ data: c.get("app").toJSON(true).auth }), - ] - .filter((n) => n.schema instanceof SecretSchema) - .map((n) => ({ - ...n, - schema: n.schema.constructor.name, - })), - }, }), ); @@ -357,4 +409,54 @@ export class SystemController extends Controller { return hono; } + + override registerMcp() { + const { mcp } = this.app.modules.ctx(); + const { version, ...appConfig } = this.app.toJSON(); + + mcp.resource("system_config", "bknd://system/config", async (c) => { + await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c); + + return c.json(this.app.toJSON(), { + title: "System Config", + }); + }) + .resource( + "system_config_module", + "bknd://system/config/{module}", + async (c, { module }) => { + await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c); + + const m = this.app.modules.get(module as any) as Module; + return c.json(m.toJSON(), { + title: `Config for ${module}`, + }); + }, + { + list: Object.keys(appConfig), + }, + ) + .resource("system_schema", "bknd://system/schema", async (c) => { + await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); + + return c.json(this.app.getSchema(), { + title: "System Schema", + }); + }) + .resource( + "system_schema_module", + "bknd://system/schema/{module}", + async (c, { module }) => { + await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c); + + const m = this.app.modules.get(module as any); + return c.json(m.getSchema().toJSON(), { + title: `Schema for ${module}`, + }); + }, + { + list: Object.keys(this.app.getSchema()), + }, + ); + } } diff --git a/app/src/ui/components/code/JsonViewer.tsx b/app/src/ui/components/code/JsonViewer.tsx index 923846b..ba2c63b 100644 --- a/app/src/ui/components/code/JsonViewer.tsx +++ b/app/src/ui/components/code/JsonViewer.tsx @@ -2,6 +2,35 @@ import { TbCopy } from "react-icons/tb"; import { JsonView } from "react-json-view-lite"; import { twMerge } from "tailwind-merge"; import { IconButton } from "../buttons/IconButton"; +import ErrorBoundary from "ui/components/display/ErrorBoundary"; +import { forwardRef, useImperativeHandle, useState } from "react"; + +export type JsonViewerProps = { + json: object | null; + title?: string; + expand?: number; + showSize?: boolean; + showCopy?: boolean; + copyIconProps?: any; + className?: string; +}; + +const style = { + basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20", + container: "ml-[-10px]", + label: "text-primary/90 font-bold font-mono mr-2", + stringValue: "text-emerald-600 dark:text-emerald-500 font-mono select-text", + numberValue: "text-sky-500 dark:text-sky-400 font-mono", + nullValue: "text-zinc-400 font-mono", + undefinedValue: "text-zinc-400 font-mono", + otherValue: "text-zinc-400 font-mono", + booleanValue: "text-orange-500 dark:text-orange-400 font-mono", + punctuation: "text-zinc-400 font-bold font-mono m-0.5", + collapsedContent: "text-zinc-400 font-mono after:content-['...']", + collapseIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['ā–¾'] mr-1.5", + expandIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['ā–ø'] mr-1.5", + noQuotesForStringValues: false, +} as any; export const JsonViewer = ({ json, @@ -11,16 +40,8 @@ export const JsonViewer = ({ showCopy = false, copyIconProps = {}, className, -}: { - json: object; - title?: string; - expand?: number; - showSize?: boolean; - showCopy?: boolean; - copyIconProps?: any; - className?: string; -}) => { - const size = showSize ? JSON.stringify(json).length : undefined; +}: JsonViewerProps) => { + const size = showSize ? (json === null ? 0 : (JSON.stringify(json)?.length ?? 0)) : undefined; const showContext = size || title || showCopy; function onCopy() { @@ -31,9 +52,10 @@ export const JsonViewer = ({
{showContext && (
- {(title || size) && ( + {(title || size !== undefined) && (
- {title && {title}} {size && ({size} Bytes)} + {title && {title}}{" "} + {size !== undefined && ({size} Bytes)}
)} {showCopy && ( @@ -43,30 +65,66 @@ export const JsonViewer = ({ )}
)} - level < expand} - style={ - { - basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20", - container: "ml-[-10px]", - label: "text-primary/90 font-bold font-mono mr-2", - stringValue: "text-emerald-500 font-mono select-text", - numberValue: "text-sky-400 font-mono", - nullValue: "text-zinc-400 font-mono", - undefinedValue: "text-zinc-400 font-mono", - otherValue: "text-zinc-400 font-mono", - booleanValue: "text-orange-400 font-mono", - punctuation: "text-zinc-400 font-bold font-mono m-0.5", - collapsedContent: "text-zinc-400 font-mono after:content-['...']", - collapseIcon: - "text-zinc-400 font-mono font-bold text-lg after:content-['ā–¾'] mr-1.5", - expandIcon: - "text-zinc-400 font-mono font-bold text-lg after:content-['ā–ø'] mr-1.5", - noQuotesForStringValues: false, - } as any - } - /> + + level < expand} + style={style} + /> +
); }; + +export type JsonViewerTabsProps = Omit & { + selected?: string; + tabs: { + [key: string]: JsonViewerProps & { + enabled?: boolean; + }; + }; +}; + +export type JsonViewerTabsRef = { + setSelected: (selected: string) => void; +}; + +export const JsonViewerTabs = forwardRef( + ({ tabs: _tabs, ...defaultProps }, ref) => { + const tabs = Object.fromEntries( + Object.entries(_tabs).filter(([_, v]) => v.enabled !== false), + ); + const [selected, setSelected] = useState(defaultProps.selected ?? Object.keys(tabs)[0]); + + useImperativeHandle(ref, () => ({ + setSelected, + })); + + return ( +
+
+ {Object.keys(tabs).map((key) => ( + + ))} +
+ {/* @ts-ignore */} + +
+ ); + }, +); diff --git a/app/src/ui/components/display/Empty.tsx b/app/src/ui/components/display/Empty.tsx index 39bf4f2..9bb7b0d 100644 --- a/app/src/ui/components/display/Empty.tsx +++ b/app/src/ui/components/display/Empty.tsx @@ -8,6 +8,7 @@ export type EmptyProps = { primary?: ButtonProps; secondary?: ButtonProps; className?: string; + children?: React.ReactNode; }; export const Empty: React.FC = ({ Icon = undefined, @@ -16,6 +17,7 @@ export const Empty: React.FC = ({ primary, secondary, className, + children, }) => (
@@ -27,6 +29,7 @@ export const Empty: React.FC = ({
{secondary &&
diff --git a/app/src/ui/components/display/ErrorBoundary.tsx b/app/src/ui/components/display/ErrorBoundary.tsx index ad9dd7d..db2f8d4 100644 --- a/app/src/ui/components/display/ErrorBoundary.tsx +++ b/app/src/ui/components/display/ErrorBoundary.tsx @@ -40,7 +40,7 @@ class ErrorBoundary extends Component { {this.props.fallback} ); } - return Error1; + return {this.state.error?.message ?? "Unknown error"}; } override render() { @@ -61,7 +61,7 @@ class ErrorBoundary extends Component { } const BaseError = ({ children }: { children: ReactNode }) => ( -
+
{children}
); diff --git a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx index 40d4598..ef93cce 100644 --- a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx +++ b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx @@ -5,8 +5,15 @@ import { twMerge } from "tailwind-merge"; import * as Formy from "ui/components/form/Formy"; import { useEvent } from "ui/hooks/use-event"; import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field"; -import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form"; +import { + FormContextOverride, + useDerivedFieldContext, + useFormContext, + useFormError, + useFormValue, +} from "./Form"; import { getLabel, getMultiSchemaMatched } from "./utils"; +import { FieldWrapper } from "ui/components/form/json-schema-form/FieldWrapper"; export type AnyOfFieldRootProps = { path?: string; @@ -47,7 +54,17 @@ const Root = ({ path = "", children }: AnyOfFieldRootProps) => { const errors = useFormError(path, { strict: true }); if (!schema) return `AnyOfField(${path}): no schema ${pointer}`; const [_selected, setSelected] = useAtom(selectedAtom); - const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null; + const { + options: { anyOfNoneSelectedMode }, + } = useFormContext(); + const selected = + _selected !== null + ? _selected + : matchedIndex > -1 + ? matchedIndex + : anyOfNoneSelectedMode === "first" + ? 0 + : null; const select = useEvent((index: number | null) => { setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined); @@ -117,15 +134,27 @@ const Select = () => { const Field = ({ name, label, ...props }: Partial) => { const { selected, selectedSchema, path, errors } = useAnyOfContext(); if (selected === null) return null; + return (
0 && "bg-red-500/10")}> - + {/* another wrap is required for primitive schemas */} +
); }; +const AnotherField = (props: Partial) => { + const { value } = useFormValue(""); + + const inputProps = { + // @todo: check, potentially just provide value + value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined, + }; + return ; +}; + export const AnyOf = { Root, Select, diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index 8357fc8..1a054f4 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -46,6 +46,7 @@ type FormState = { type FormOptions = { debug?: boolean; keepEmpty?: boolean; + anyOfNoneSelectedMode?: "none" | "first"; }; export type FormContext = { @@ -190,7 +191,7 @@ export function Form< root: "", path: "", }), - [schema, initialValues], + [schema, initialValues, options], ) as any; return ( diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts index 6f3e206..333bba3 100644 --- a/app/src/ui/components/form/json-schema-form/utils.ts +++ b/app/src/ui/components/form/json-schema-form/utils.ts @@ -62,20 +62,25 @@ export function getParentPointer(pointer: string) { } export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data?: any) { - if (pointer === "#/" || !schema) { + try { + if (pointer === "#/" || !schema) { + return false; + } + + const childSchema = lib.getSchema({ pointer, data, schema }); + if (typeof childSchema === "object" && "const" in childSchema) { + return true; + } + + const parentPointer = getParentPointer(pointer); + const parentSchema = lib.getSchema({ pointer: parentPointer, data }); + const required = parentSchema?.required?.includes(pointer.split("/").pop()!); + + return !!required; + } catch (e) { + console.error("isRequired", { pointer, schema, data, e }); return false; } - - const childSchema = lib.getSchema({ pointer, data, schema }); - if (typeof childSchema === "object" && "const" in childSchema) { - return true; - } - - const parentPointer = getParentPointer(pointer); - const parentSchema = lib.getSchema({ pointer: parentPointer, data }); - const required = parentSchema?.required?.includes(pointer.split("/").pop()!); - - return !!required; } export type IsTypeType = diff --git a/app/src/ui/components/form/json-schema/JsonvTsValidator.ts b/app/src/ui/components/form/json-schema/JsonvTsValidator.ts index ae744fd..2b4340f 100644 --- a/app/src/ui/components/form/json-schema/JsonvTsValidator.ts +++ b/app/src/ui/components/form/json-schema/JsonvTsValidator.ts @@ -1,4 +1,4 @@ -import * as s from "jsonv-ts"; +import { s } from "bknd/utils"; import type { CustomValidator, diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index 9018e29..d749ac3 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -19,15 +19,9 @@ import { appShellStore } from "ui/store"; import { useLocation } from "wouter"; export function Root({ children }: { children: React.ReactNode }) { - const sidebarWidth = appShellStore((store) => store.sidebarWidth); return ( -
+
{children}
@@ -97,10 +91,24 @@ export function Main({ children }) { ); } -export function Sidebar({ children }) { - const open = appShellStore((store) => store.sidebarOpen); - const close = appShellStore((store) => store.closeSidebar); +export function Sidebar({ + children, + name = "default", + handle = "right", + minWidth, + maxWidth, +}: { + children: React.ReactNode; + name?: string; + handle?: "right" | "left"; + minWidth?: number; + maxWidth?: number; +}) { + const open = appShellStore((store) => store.sidebars[name]?.open); + const close = appShellStore((store) => store.closeSidebar(name)); + const width = appShellStore((store) => store.sidebars[name]?.width ?? 350); const ref = useClickOutside(close, ["mouseup", "touchend"]); //, [document.getElementById("header")]); + const sidebarRef = useRef(null!); const [location] = useLocation(); const closeHandler = () => { @@ -115,16 +123,35 @@ export function Sidebar({ children }) { return ( <> + {handle === "left" && ( + + )} - + {handle === "right" && ( + + )}