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__/adapter/adapter.test.ts b/app/__test__/adapter/adapter.test.ts index f7d623c..ee9a87e 100644 --- a/app/__test__/adapter/adapter.test.ts +++ b/app/__test__/adapter/adapter.test.ts @@ -9,16 +9,16 @@ beforeAll(disableConsoleLog); afterAll(enableConsoleLog); describe("adapter", () => { - it("makes config", () => { - expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({}); - expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual( - {}, - ); + it("makes config", async () => { + expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({}); + expect( + omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]), + ).toEqual({}); // merges everything returned from `app` with the config expect( omitKeys( - adapter.makeConfig( + await adapter.makeConfig( { app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) }, { env: { TEST: "test" } }, ), 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..46ad76e 100644 --- a/app/__test__/app/AppServer.spec.ts +++ b/app/__test__/app/AppServer.spec.ts @@ -13,6 +13,10 @@ describe("AppServer", () => { allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], }, + mcp: { + enabled: false, + path: "/api/system/mcp", + }, }); } @@ -31,6 +35,10 @@ describe("AppServer", () => { allow_methods: ["GET", "POST"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], }, + mcp: { + enabled: false, + path: "/api/system/mcp", + }, }); } }); 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 f149231..2d0caa9 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; /** @@ -256,7 +258,19 @@ async function buildAdapters() { ), tsup.build(baseConfig("astro")), tsup.build(baseConfig("aws")), - tsup.build(baseConfig("cloudflare")), + tsup.build( + baseConfig("cloudflare", { + external: ["wrangler", "node:process"], + }), + ), + tsup.build( + baseConfig("cloudflare/proxy", { + entry: ["src/adapter/cloudflare/proxy.ts"], + outDir: "dist/adapter/cloudflare", + metafile: false, + external: [/bknd/, "wrangler", "node:process"], + }), + ), tsup.build({ ...baseConfig("vite"), 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..08dcb7c 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.16.1", + "version": "0.17.0-rc.2", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -39,11 +39,12 @@ "test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail", "test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "test:vitest:coverage": "vitest run --coverage", - "test:e2e": "playwright test", - "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": "VITE_DB_URL=:memory: playwright test", + "test:e2e:adapters": "VITE_DB_URL=:memory: bun run e2e/adapters.ts", + "test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui", + "test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug", + "test:e2e:report": "VITE_DB_URL=:memory: 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.2", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -196,6 +197,11 @@ "import": "./dist/adapter/cloudflare/index.js", "require": "./dist/adapter/cloudflare/index.js" }, + "./adapter/cloudflare/proxy": { + "types": "./dist/types/adapter/cloudflare/proxy.d.ts", + "import": "./dist/adapter/cloudflare/proxy.js", + "require": "./dist/adapter/cloudflare/proxy.js" + }, "./adapter": { "types": "./dist/types/adapter/index.d.ts", "import": "./dist/adapter/index.js" diff --git a/app/src/Api.ts b/app/src/Api.ts index cce9156..d9fb8e4 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -4,7 +4,7 @@ import { DataApi, type DataApiOptions } from "data/api/DataApi"; import { decode } from "hono/jwt"; import { MediaApi, type MediaApiOptions } from "media/api/MediaApi"; import { SystemApi } from "modules/SystemApi"; -import { omitKeys } from "core/utils"; +import { omitKeys } from "bknd/utils"; import type { BaseModuleApiOptions } from "modules"; export type TApiUser = SafeUser; diff --git a/app/src/App.ts b/app/src/App.ts index 832ed70..bd62092 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,5 +1,5 @@ import type { CreateUserPayload } from "auth/AppAuth"; -import { $console } from "core/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"; @@ -23,13 +23,34 @@ import type { IEmailDriver, ICacheDriver } from "core/drivers"; import { Api, type ApiOptions } from "Api"; export type AppPluginConfig = { + /** + * The name of the plugin. + */ name: string; + /** + * The schema of the plugin. + */ schema?: () => MaybePromise | void>; + /** + * Called before the app is built. + */ beforeBuild?: () => MaybePromise; + /** + * Called after the app is built. + */ onBuilt?: () => MaybePromise; + /** + * Called when the server is initialized. + */ onServerInit?: (server: Hono) => MaybePromise; - onFirstBoot?: () => MaybePromise; + /** + * Called when the app is booted. + */ onBoot?: () => MaybePromise; + /** + * Called when the app is first booted. + */ + onFirstBoot?: () => MaybePromise; }; export type AppPlugin = (app: App) => AppPluginConfig; @@ -96,6 +117,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/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index c3d271b..03689d5 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -11,7 +11,7 @@ type BunEnv = Bun.Env; export type BunBkndConfig = RuntimeBkndConfig & Omit; export async function createApp( - { distPath, ...config }: BunBkndConfig = {}, + { distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig = {}, args: Env = {} as Env, opts?: RuntimeOptions, ) { @@ -20,7 +20,11 @@ export async function createApp( return await createRuntimeApp( { - serveStatic: serveStatic({ root }), + serveStatic: + _serveStatic ?? + serveStatic({ + root, + }), ...config, }, args ?? (process.env as Env), diff --git a/app/src/adapter/cloudflare/bindings.ts b/app/src/adapter/cloudflare/bindings.ts index 0b68524..891081e 100644 --- a/app/src/adapter/cloudflare/bindings.ts +++ b/app/src/adapter/cloudflare/bindings.ts @@ -1,3 +1,5 @@ +import { inspect } from "node:util"; + export type BindingTypeMap = { D1Database: D1Database; KVNamespace: KVNamespace; @@ -13,8 +15,9 @@ export function getBindings(env: any, type: T): Bindin for (const key in env) { try { if ( - env[key] && - ((env[key] as any).constructor.name === type || String(env[key]) === `[object ${type}]`) + (env[key] as any).constructor.name === type || + String(env[key]) === `[object ${type}]` || + inspect(env[key]).includes(type) ) { bindings.push({ key, diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts index 5cdde1a..401722c 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -18,7 +18,7 @@ describe("cf adapter", () => { }); it("makes config", async () => { - const staticConfig = makeConfig( + const staticConfig = await makeConfig( { connection: { url: DB_URL }, initialConfig: { data: { basepath: DB_URL } }, @@ -28,7 +28,7 @@ describe("cf adapter", () => { expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); expect(staticConfig.connection).toBeDefined(); - const dynamicConfig = makeConfig( + const dynamicConfig = await makeConfig( { app: (env) => ({ initialConfig: { data: { basepath: env.DB_URL } }, diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 427f8e4..4cd03ca 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -5,9 +5,8 @@ import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; import { getFresh } from "./modes/fresh"; import { getCached } from "./modes/cached"; -import { getDurable } from "./modes/durable"; -import type { App } from "bknd"; -import { $console } from "core/utils"; +import type { App, MaybePromise } from "bknd"; +import { $console } from "bknd/utils"; declare global { namespace Cloudflare { @@ -17,12 +16,11 @@ declare global { export type CloudflareEnv = Cloudflare.Env; export type CloudflareBkndConfig = RuntimeBkndConfig & { - mode?: "warm" | "fresh" | "cache" | "durable"; - bindings?: (args: Env) => { + mode?: "warm" | "fresh" | "cache"; + bindings?: (args: Env) => MaybePromise<{ kv?: KVNamespace; - dobj?: DurableObjectNamespace; db?: D1Database; - }; + }>; d1?: { session?: boolean; transport?: "header" | "cookie"; @@ -93,8 +91,6 @@ export function serve( case "cache": app = await getCached(config, context); break; - case "durable": - return await getDurable(config, context); default: throw new Error(`Unknown mode ${mode}`); } diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index da5af07..0a70249 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -9,7 +9,7 @@ import { d1Sqlite } from "./connection/D1Connection"; import type { CloudflareBkndConfig, CloudflareEnv } from "."; import { App } from "bknd"; import type { Context, ExecutionContext } from "hono"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { setCookie } from "hono/cookie"; export const constants = { @@ -89,7 +89,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig) { } let media_registered: boolean = false; -export function makeConfig( +export async function makeConfig( config: CloudflareBkndConfig, args?: CfMakeConfigArgs, ) { @@ -102,7 +102,7 @@ export function makeConfig( media_registered = true; } - const appConfig = makeAdapterConfig(config, args?.env); + const appConfig = await makeAdapterConfig(config, args?.env); // if connection instance is given, don't do anything // other than checking if D1 session is defined @@ -115,12 +115,12 @@ export function makeConfig( } // if connection is given, try to open with unified sqlite adapter } else if (appConfig.connection) { - appConfig.connection = sqlite(appConfig.connection); + appConfig.connection = sqlite(appConfig.connection) as any; // if connection is not given, but env is set // try to make D1 from bindings } else if (args?.env) { - const bindings = config.bindings?.(args?.env); + const bindings = await config.bindings?.(args?.env); const sessionHelper = d1SessionHelper(config); const sessionId = sessionHelper.get(args.request); let session: D1DatabaseSession | undefined; diff --git a/app/src/adapter/cloudflare/connection/DoConnection.ts b/app/src/adapter/cloudflare/connection/DoConnection.ts index 91ae5ec..5a13b91 100644 --- a/app/src/adapter/cloudflare/connection/DoConnection.ts +++ b/app/src/adapter/cloudflare/connection/DoConnection.ts @@ -3,16 +3,16 @@ import { genericSqlite, type GenericSqliteConnection } from "bknd"; import type { QueryResult } from "kysely"; -export type D1SqliteConnection = GenericSqliteConnection; +export type DoSqliteConnection = GenericSqliteConnection; export type DurableObjecSql = DurableObjectState["storage"]["sql"]; -export type D1ConnectionConfig = +export type DoConnectionConfig = | DurableObjectState | { sql: DB; }; -export function doSqlite(config: D1ConnectionConfig) { +export function doSqlite(config: DoConnectionConfig) { const db = "sql" in config ? config.sql : config.storage.sql; return genericSqlite( @@ -21,7 +21,7 @@ export function doSqlite(config: D1ConnectionConfig< (utils) => { // must be async to work with the miniflare mock const getStmt = async (sql: string, parameters?: any[] | readonly any[]) => - await db.exec(sql, ...(parameters || [])); + db.exec(sql, ...(parameters || [])); const mapResult = ( cursor: SqlStorageCursor>, diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index bc4e294..a7d249a 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -3,8 +3,8 @@ import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection"; export * from "./cloudflare-workers.adapter"; export { makeApp, getFresh } from "./modes/fresh"; export { getCached } from "./modes/cached"; -export { DurableBkndApp, getDurable } from "./modes/durable"; export { d1Sqlite, type D1ConnectionConfig }; +export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection"; export { getBinding, getBindings, @@ -15,6 +15,7 @@ export { export { constants } from "./config"; export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter"; export { registries } from "bknd"; +export { devFsVitePlugin, devFsWrite } from "./vite"; // for compatibility with old code export function d1( diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index fc1d3c4..fdbed21 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -8,7 +8,7 @@ export async function getCached( args: Context, ) { const { env, ctx } = args; - const { kv } = config.bindings?.(env)!; + const { kv } = await config.bindings?.(env)!; if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); const key = config.key ?? "app"; diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts deleted file mode 100644 index 4812b0c..0000000 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { DurableObject } from "cloudflare:workers"; -import type { App, CreateAppConfig } from "bknd"; -import { createRuntimeApp, makeConfig } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; -import { constants, registerAsyncsExecutionContext } from "../config"; -import { $console } from "core/utils"; - -export async function getDurable( - config: CloudflareBkndConfig, - ctx: Context, -) { - const { dobj } = config.bindings?.(ctx.env)!; - if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings"); - const key = config.key ?? "app"; - - if ([config.onBuilt, config.beforeBuild].some((x) => x)) { - $console.warn("onBuilt and beforeBuild are not supported with DurableObject mode"); - } - - const start = performance.now(); - - const id = dobj.idFromName(key); - const stub = dobj.get(id) as unknown as DurableBkndApp; - - const create_config = makeConfig(config, ctx.env); - - const res = await stub.fire(ctx.request, { - config: create_config, - keepAliveSeconds: config.keepAliveSeconds, - }); - - const headers = new Headers(res.headers); - headers.set("X-TTDO", String(performance.now() - start)); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); -} - -export class DurableBkndApp extends DurableObject { - protected id = Math.random().toString(36).slice(2); - protected app?: App; - protected interval?: any; - - async fire( - request: Request, - options: { - config: CreateAppConfig; - html?: string; - keepAliveSeconds?: number; - setAdminHtml?: boolean; - }, - ) { - let buildtime = 0; - if (!this.app) { - const start = performance.now(); - const config = options.config; - - // change protocol to websocket if libsql - if ( - config?.connection && - "type" in config.connection && - config.connection.type === "libsql" - ) { - //config.connection.config.protocol = "wss"; - } - - this.app = await createRuntimeApp({ - ...config, - onBuilt: async (app) => { - registerAsyncsExecutionContext(app, this.ctx); - app.modules.server.get(constants.do_endpoint, async (c) => { - // @ts-ignore - const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; - return c.json({ - id: this.id, - keepAliveSeconds: options?.keepAliveSeconds ?? 0, - colo: context.colo, - }); - }); - - await this.onBuilt(app); - }, - adminOptions: { html: options.html }, - beforeBuild: async (app) => { - await this.beforeBuild(app); - }, - }); - - buildtime = performance.now() - start; - } - - if (options?.keepAliveSeconds) { - this.keepAlive(options.keepAliveSeconds); - } - - const res = await this.app!.fetch(request); - const headers = new Headers(res.headers); - headers.set("X-BuildTime", buildtime.toString()); - headers.set("X-DO-ID", this.id); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); - } - - async onBuilt(app: App) {} - - async beforeBuild(app: App) {} - - protected keepAlive(seconds: number) { - if (this.interval) { - clearInterval(this.interval); - } - - let i = 0; - this.interval = setInterval(() => { - i += 1; - if (i === seconds) { - console.log("cleared"); - clearInterval(this.interval); - - // ping every 30 seconds - } else if (i % 30 === 0) { - console.log("ping"); - this.app?.modules.ctx().connection.ping(); - } - }, 1000); - } -} diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index 7fb37e3..5a3ad22 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -7,7 +7,7 @@ export async function makeApp( args?: CfMakeConfigArgs, opts?: RuntimeOptions, ) { - return await createRuntimeApp(makeConfig(config, args), args?.env, opts); + return await createRuntimeApp(await makeConfig(config, args), args?.env, opts); } export async function getFresh( diff --git a/app/src/adapter/cloudflare/proxy.ts b/app/src/adapter/cloudflare/proxy.ts new file mode 100644 index 0000000..ddbd4b3 --- /dev/null +++ b/app/src/adapter/cloudflare/proxy.ts @@ -0,0 +1,67 @@ +import { + d1Sqlite, + getBinding, + registerMedia, + type CloudflareBkndConfig, + type CloudflareEnv, +} from "bknd/adapter/cloudflare"; +import type { PlatformProxy } from "wrangler"; +import process from "node:process"; + +export type WithPlatformProxyOptions = { + /** + * By default, proxy is used if the PROXY environment variable is set to 1. + * You can override/force this by setting this option. + */ + useProxy?: boolean; +}; + +export function withPlatformProxy( + config?: CloudflareBkndConfig, + opts?: WithPlatformProxyOptions, +) { + const use_proxy = + typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1"; + let proxy: PlatformProxy | undefined; + + async function getEnv(env?: Env): Promise { + if (use_proxy) { + if (!proxy) { + const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy); + proxy = await getPlatformProxy(); + setTimeout(proxy?.dispose, 1000); + } + return proxy.env as unknown as Env; + } + return env || ({} as Env); + } + + return { + ...config, + beforeBuild: async (app, registries) => { + if (!use_proxy) return; + const env = await getEnv(); + registerMedia(env, registries as any); + await config?.beforeBuild?.(app, registries); + }, + bindings: async (env) => { + return (await config?.bindings?.(await getEnv(env))) || {}; + }, + // @ts-ignore + app: async (_env) => { + const env = await getEnv(_env); + + if (config?.app === undefined && use_proxy) { + const binding = getBinding(env, "D1Database"); + return { + connection: d1Sqlite({ + binding: binding.value, + }), + }; + } else if (typeof config?.app === "function") { + return config?.app(env); + } + return config?.app || {}; + }, + } satisfies CloudflareBkndConfig; +} diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index a1edf58..e257b7c 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -1,4 +1,4 @@ -import { registries, isDebug, guessMimeType } from "bknd"; +import { registries as $registries, isDebug, guessMimeType } from "bknd"; import { getBindings } from "../bindings"; import { s } from "bknd/utils"; import { StorageAdapter, type FileBody } from "bknd"; @@ -12,7 +12,10 @@ export function makeSchema(bindings: string[] = []) { ); } -export function registerMedia(env: Record) { +export function registerMedia( + env: Record, + registries: typeof $registries = $registries, +) { const r2_bindings = getBindings(env, "R2Bucket"); registries.media.register( diff --git a/app/src/adapter/cloudflare/vite.ts b/app/src/adapter/cloudflare/vite.ts new file mode 100644 index 0000000..c8c073e --- /dev/null +++ b/app/src/adapter/cloudflare/vite.ts @@ -0,0 +1,135 @@ +import type { Plugin } from "vite"; +import { writeFile as nodeWriteFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +/** + * Vite plugin that provides Node.js filesystem access during development + * by injecting a polyfill into the SSR environment + */ +export function devFsVitePlugin({ + verbose = false, + configFile = "bknd.config.ts", +}: { + verbose?: boolean; + configFile?: string; +}): Plugin { + let isDev = false; + let projectRoot = ""; + + return { + name: "dev-fs-plugin", + enforce: "pre", + configResolved(config) { + isDev = config.command === "serve"; + projectRoot = config.root; + }, + configureServer(server) { + if (!isDev) return; + + // Intercept stdout to watch for our write requests + const originalStdoutWrite = process.stdout.write; + process.stdout.write = function (chunk: any, encoding?: any, callback?: any) { + const output = chunk.toString(); + + // Check if this output contains our special write request + if (output.includes("{{DEV_FS_WRITE_REQUEST}}")) { + try { + // Extract the JSON from the log line + const match = output.match(/{{DEV_FS_WRITE_REQUEST}} ({.*})/); + if (match) { + const writeRequest = JSON.parse(match[1]); + if (writeRequest.type === "DEV_FS_WRITE_REQUEST") { + if (verbose) { + console.debug("[dev-fs-plugin] Intercepted write request via stdout"); + } + + // Process the write request immediately + (async () => { + try { + const fullPath = resolve(projectRoot, writeRequest.filename); + await nodeWriteFile(fullPath, writeRequest.data); + if (verbose) { + console.debug("[dev-fs-plugin] File written successfully!"); + } + } catch (error) { + console.error("[dev-fs-plugin] Error writing file:", error); + } + })(); + + // Don't output the raw write request to console + return true; + } + } + } catch (error) { + // Not a valid write request, continue with normal output + } + } + + // @ts-ignore + // biome-ignore lint: + return originalStdoutWrite.apply(process.stdout, arguments); + }; + + // Restore stdout when server closes + server.httpServer?.on("close", () => { + process.stdout.write = originalStdoutWrite; + }); + }, + // @ts-ignore + transform(code, id, options) { + // Only transform in SSR mode during development + if (!isDev || !options?.ssr) return; + + // Check if this is the bknd config file + if (id.includes(configFile)) { + if (verbose) { + console.debug("[dev-fs-plugin] Transforming", configFile); + } + + // Inject our filesystem polyfill at the top of the file + const polyfill = ` +// Dev-fs polyfill injected by vite-plugin-dev-fs +if (typeof globalThis !== 'undefined') { + globalThis.__devFsPolyfill = { + writeFile: async (filename, data) => { + ${verbose ? "console.debug('dev-fs polyfill: Intercepting write request for', filename);" : ""} + + // Use console logging as a communication channel + // The main process will watch for this specific log pattern + const writeRequest = { + type: 'DEV_FS_WRITE_REQUEST', + filename: filename, + data: data, + timestamp: Date.now() + }; + + // Output as a specially formatted console message + console.log('{{DEV_FS_WRITE_REQUEST}}', JSON.stringify(writeRequest)); + ${verbose ? "console.debug('dev-fs polyfill: Write request sent via console');" : ""} + + return Promise.resolve(); + } + }; +} +`; + return polyfill + code; + } + }, + }; +} + +// Write function that uses the dev-fs polyfill injected by our Vite plugin +export async function devFsWrite(filename: string, data: string): Promise { + try { + // Check if the dev-fs polyfill is available (injected by our Vite plugin) + if (typeof globalThis !== "undefined" && (globalThis as any).__devFsPolyfill) { + return (globalThis as any).__devFsPolyfill.writeFile(filename, data); + } + + // Fallback to Node.js fs for other environments (Node.js, Bun) + const { writeFile } = await import("node:fs/promises"); + return writeFile(filename, data); + } catch (error) { + console.error("[dev-fs-write] Error writing file:", error); + } +} diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 65c749b..1990b9f 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -1,13 +1,21 @@ -import { config as $config, App, type CreateAppConfig, Connection, guessMimeType } from "bknd"; +import { + config as $config, + App, + type CreateAppConfig, + Connection, + guessMimeType, + type MaybePromise, + registries as $registries, +} from "bknd"; import { $console } from "bknd/utils"; import type { Context, MiddlewareHandler, Next } from "hono"; import type { AdminControllerOptions } from "modules/server/AdminController"; import type { Manifest } from "vite"; export type BkndConfig = CreateAppConfig & { - app?: CreateAppConfig | ((args: Args) => CreateAppConfig); + app?: CreateAppConfig | ((args: Args) => MaybePromise); onBuilt?: (app: App) => Promise; - beforeBuild?: (app: App) => Promise; + beforeBuild?: (app: App, registries?: typeof $registries) => Promise; buildConfig?: Parameters[0]; }; @@ -30,10 +38,10 @@ export type DefaultArgs = { [key: string]: any; }; -export function makeConfig( +export async function makeConfig( config: BkndConfig, args?: Args, -): CreateAppConfig { +): Promise { let additionalConfig: CreateAppConfig = {}; const { app, ...rest } = config; if (app) { @@ -41,7 +49,7 @@ export function makeConfig( if (!args) { throw new Error("args is required when config.app is a function"); } - additionalConfig = app(args); + additionalConfig = await app(args); } else { additionalConfig = app; } @@ -60,7 +68,7 @@ export async function createAdapterApp( ); } - await config.beforeBuild?.(app); + await config.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } @@ -131,7 +139,7 @@ export async function createRuntimeApp( "sync", ); - await config.beforeBuild?.(app); + await config.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 8ee5423..a0c6072 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,8 +1,8 @@ -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"; -import { $console, secureRandomString, transformObject } from "core/utils"; +import { $console, secureRandomString, transformObject } from "bknd/utils"; import type { Entity, EntityManager } from "data/entities"; import { em, entity, enumm, type FieldSchema } from "data/prototype"; import { Module } from "modules/Module"; @@ -14,6 +14,7 @@ import { usersFields } from "./auth-entities"; import { Authenticator } from "./authenticate/Authenticator"; import { Role } from "./authorize/Role"; +export type UsersFields = typeof AppAuth.usersFields; export type UserFieldSchema = FieldSchema; declare module "bknd" { interface Users extends AppEntity, UserFieldSchema {} @@ -87,6 +88,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 +178,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/AppUserPool.ts b/app/src/auth/AppUserPool.ts index 128de6c..d5679b6 100644 --- a/app/src/auth/AppUserPool.ts +++ b/app/src/auth/AppUserPool.ts @@ -1,6 +1,6 @@ import { AppAuth } from "auth/AppAuth"; import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { pick } from "lodash-es"; import { InvalidConditionsException, diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index b039635..33c0df6 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,119 @@ export class AuthController extends Controller { return hono; } + + override registerMcp(): void { + const { mcp } = this.auth.ctx; + const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]); + + 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: idType.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: idType.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/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 1ee6d36..e7e97b8 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,11 +1,10 @@ import type { User } from "bknd"; import type { Authenticator } from "auth/authenticate/Authenticator"; import { InvalidCredentialsException } from "auth/errors"; -import { hash, $console } from "core/utils"; +import { hash, $console, s, parse, jsc } from "bknd/utils"; import { Hono } from "hono"; import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import { AuthStrategy } from "./Strategy"; -import { s, parse, jsc } from "bknd/utils"; const schema = s .object({ diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 09d36fb..a89b98d 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,5 +1,5 @@ import { Exception } from "core/errors"; -import { $console, objectTransform } from "core/utils"; +import { $console, objectTransform } from "bknd/utils"; import { Permission } from "core/security/Permission"; import type { Context } from "hono"; import type { ServerEnv } from "modules/Controller"; diff --git a/app/src/cli/commands/config.ts b/app/src/cli/commands/config.ts index 81e6cb7..154453f 100644 --- a/app/src/cli/commands/config.ts +++ b/app/src/cli/commands/config.ts @@ -1,15 +1,35 @@ import { getDefaultConfig } from "modules/ModuleManager"; import type { CliCommand } from "../types"; +import { makeAppFromEnv } from "cli/commands/run"; +import { writeFile } from "node:fs/promises"; +import c from "picocolors"; +import { withConfigOptions } from "cli/utils/options"; export const config: CliCommand = (program) => { - program - .command("config") - .description("get default config") + withConfigOptions(program.command("config")) + .description("get app config") .option("--pretty", "pretty print") - .action((options) => { - const config = getDefaultConfig(); + .option("--default", "use default config") + .option("--secrets", "include secrets in output") + .option("--out ", "output file") + .action(async (options) => { + let config: any = {}; - // biome-ignore lint/suspicious/noConsoleLog: - console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config)); + if (options.default) { + config = getDefaultConfig(); + } else { + const app = await makeAppFromEnv(options); + config = app.toJSON(options.secrets); + } + + config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config); + + console.info(""); + if (options.out) { + await writeFile(options.out, config); + console.info(`Config written to ${c.cyan(options.out)}`); + } else { + console.info(JSON.parse(config)); + } }); }; diff --git a/app/src/cli/commands/create/templates/cloudflare.ts b/app/src/cli/commands/create/templates/cloudflare.ts index 0bbab03..1cac856 100644 --- a/app/src/cli/commands/create/templates/cloudflare.ts +++ b/app/src/cli/commands/create/templates/cloudflare.ts @@ -1,7 +1,6 @@ import * as $p from "@clack/prompts"; -import { overrideJson, overridePackageJson } from "cli/commands/create/npm"; -import { typewriter, wait } from "cli/utils/cli"; -import { uuid } from "core/utils"; +import { overrideJson } from "cli/commands/create/npm"; +import { typewriter } from "cli/utils/cli"; import c from "picocolors"; import type { Template, TemplateSetupCtx } from "."; import { exec } from "cli/utils/sys"; diff --git a/app/src/cli/commands/index.ts b/app/src/cli/commands/index.ts index 9f63382..ad014fb 100644 --- a/app/src/cli/commands/index.ts +++ b/app/src/cli/commands/index.ts @@ -6,3 +6,5 @@ export { user } from "./user"; export { create } from "./create"; export { copyAssets } from "./copy-assets"; export { types } from "./types"; +export { mcp } from "./mcp/mcp"; +export { sync } from "./sync"; diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts new file mode 100644 index 0000000..0ec0564 --- /dev/null +++ b/app/src/cli/commands/mcp/mcp.ts @@ -0,0 +1,82 @@ +import type { CliCommand } from "cli/types"; +import { makeAppFromEnv } from "../run"; +import { getSystemMcp } from "modules/mcp/system-mcp"; +import { $console, stdioTransport } from "bknd/utils"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; + +export const mcp: CliCommand = (program) => + withConfigOptions(program.command("mcp")) + .description("mcp server stdio transport") + .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: WithConfigOptions<{ + verbose?: boolean; + 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/run/platform.ts b/app/src/cli/commands/run/platform.ts index bc3379b..061d44c 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import type { MiddlewareHandler } from "hono"; import open from "open"; import { fileExists, getRelativeDistPath } from "../../utils/sys"; diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 24c14b5..0e4efb0 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -17,6 +17,7 @@ import { } from "./platform"; import { createRuntimeApp, makeConfig } from "bknd/adapter"; import { colorizeConsole, isBun } from "bknd/utils"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; const env_files = [".env", ".dev.vars"]; dotenv.config({ @@ -25,8 +26,7 @@ dotenv.config({ const is_bun = isBun(); export const run: CliCommand = (program) => { - program - .command("run") + withConfigOptions(program.command("run")) .description("run an instance") .addOption( new Option("-p, --port ", "port to run on") @@ -41,12 +41,6 @@ export const run: CliCommand = (program) => { "db-token", ]), ) - .addOption(new Option("-c, --config ", "config file")) - .addOption( - new Option("--db-url ", "database url, can be any valid sqlite url").conflicts( - "config", - ), - ) .addOption( new Option("--server ", "server type") .choices(PLATFORMS) @@ -77,21 +71,21 @@ async function makeApp(config: MakeAppConfig) { } export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { - const config = makeConfig(_config, process.env); + const config = await makeConfig(_config, process.env); return makeApp({ ...config, server: { platform }, }); } -type RunOptions = { +type RunOptions = WithConfigOptions<{ port: number; memory?: boolean; config?: string; dbUrl?: string; server: Platform; open?: boolean; -}; +}>; export async function makeAppFromEnv(options: Partial = {}) { const configFilePath = await getConfigPath(options.config); diff --git a/app/src/cli/commands/sync.ts b/app/src/cli/commands/sync.ts new file mode 100644 index 0000000..d9b3ed5 --- /dev/null +++ b/app/src/cli/commands/sync.ts @@ -0,0 +1,45 @@ +import type { CliCommand } from "../types"; +import { makeAppFromEnv } from "cli/commands/run"; +import { writeFile } from "node:fs/promises"; +import c from "picocolors"; +import { withConfigOptions } from "cli/utils/options"; + +export const sync: CliCommand = (program) => { + withConfigOptions(program.command("sync")) + .description("sync database") + .option("--dump", "dump operations to console instead of executing them") + .option("--drop", "include destructive DDL operations") + .option("--out ", "output file") + .option("--sql", "use sql output") + .action(async (options) => { + const app = await makeAppFromEnv(options); + const schema = app.em.schema(); + const stmts = await schema.sync({ drop: options.drop }); + + console.info(""); + if (stmts.length === 0) { + console.info(c.yellow("No changes to sync")); + process.exit(0); + } + // @todo: currently assuming parameters aren't used + const sql = stmts.map((d) => d.sql).join(";\n") + ";"; + + if (options.dump) { + if (options.out) { + const output = options.sql ? sql : JSON.stringify(stmts, null, 2); + await writeFile(options.out, output); + console.info(`SQL written to ${c.cyan(options.out)}`); + } else { + console.info(options.sql ? c.cyan(sql) : stmts); + } + + process.exit(0); + } + + await schema.sync({ force: true, drop: options.drop }); + console.info(c.cyan(sql)); + + console.info(`${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`); + console.info(`${c.green("Database synced")}`); + }); +}; diff --git a/app/src/cli/commands/types/types.ts b/app/src/cli/commands/types/types.ts index 3d53618..b545d61 100644 --- a/app/src/cli/commands/types/types.ts +++ b/app/src/cli/commands/types/types.ts @@ -4,34 +4,35 @@ import { makeAppFromEnv } from "cli/commands/run"; import { EntityTypescript } from "data/entities/EntityTypescript"; import { writeFile } from "cli/utils/sys"; import c from "picocolors"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; export const types: CliCommand = (program) => { - program - .command("types") + withConfigOptions(program.command("types")) .description("generate types") .addOption(new Option("-o, --outfile ", "output file").default("bknd-types.d.ts")) - .addOption(new Option("--no-write", "do not write to file").default(true)) + .addOption(new Option("--dump", "dump types to console instead of writing to file")) .action(action); }; async function action({ outfile, - write, -}: { + dump, + ...options +}: WithConfigOptions<{ outfile: string; - write: boolean; -}) { + dump: boolean; +}>) { const app = await makeAppFromEnv({ server: "node", + ...options, }); - await app.build(); const et = new EntityTypescript(app.em); - if (write) { + if (dump) { + console.info(et.toString()); + } else { await writeFile(outfile, et.toString()); console.info(`\nTypes written to ${c.cyan(outfile)}`); - } else { - console.info(et.toString()); } } diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 4f4db7c..fb4bd4a 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -9,13 +9,12 @@ import type { PasswordStrategy } from "auth/authenticate/strategies"; import { makeAppFromEnv } from "cli/commands/run"; import type { CliCommand } from "cli/types"; import { Argument } from "commander"; -import { $console } from "core/utils"; +import { $console, isBun } from "bknd/utils"; import c from "picocolors"; -import { isBun } from "core/utils"; +import { withConfigOptions, type WithConfigOptions } from "cli/utils/options"; export const user: CliCommand = (program) => { - program - .command("user") + withConfigOptions(program.command("user")) .description("create/update users, or generate a token (auth)") .addArgument( new Argument("", "action to perform").choices(["create", "update", "token"]), @@ -23,8 +22,10 @@ export const user: CliCommand = (program) => { .action(action); }; -async function action(action: "create" | "update" | "token", options: any) { +async function action(action: "create" | "update" | "token", options: WithConfigOptions) { const app = await makeAppFromEnv({ + config: options.config, + dbUrl: options.dbUrl, server: "node", }); @@ -85,9 +86,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", @@ -100,7 +98,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); @@ -118,26 +119,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/options.ts b/app/src/cli/utils/options.ts new file mode 100644 index 0000000..26a9abb --- /dev/null +++ b/app/src/cli/utils/options.ts @@ -0,0 +1,16 @@ +import { type Command, Option } from "commander"; + +export function withConfigOptions(program: Command) { + return program + .addOption(new Option("-c, --config ", "config file")) + .addOption( + new Option("--db-url ", "database url, can be any valid sqlite url").conflicts( + "config", + ), + ); +} + +export type WithConfigOptions = { + config?: string; + dbUrl?: string; +} & CustomOptions; diff --git a/app/src/cli/utils/sys.ts b/app/src/cli/utils/sys.ts index 56ae32e..e1fd340 100644 --- a/app/src/cli/utils/sys.ts +++ b/app/src/cli/utils/sys.ts @@ -1,4 +1,4 @@ -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { execSync, exec as nodeExec } from "node:child_process"; import { readFile, writeFile as nodeWriteFile } from "node:fs/promises"; import path from "node:path"; @@ -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/drivers/email/mailchannels.ts b/app/src/core/drivers/email/mailchannels.ts index 7478ef5..540355a 100644 --- a/app/src/core/drivers/email/mailchannels.ts +++ b/app/src/core/drivers/email/mailchannels.ts @@ -1,4 +1,4 @@ -import { mergeObject, type RecursivePartial } from "core/utils"; +import { mergeObject, type RecursivePartial } from "bknd/utils"; import type { IEmailDriver } from "./index"; export type MailchannelsEmailOptions = { diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 78db931..8370f2b 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -1,6 +1,6 @@ import { type Event, type EventClass, InvalidEventReturn } from "./Event"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; export type RegisterListenerConfig = | ListenerMode 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/numbers.ts b/app/src/core/utils/numbers.ts index e9b458b..8e9c038 100644 --- a/app/src/core/utils/numbers.ts +++ b/app/src/core/utils/numbers.ts @@ -14,10 +14,10 @@ export function ensureInt(value?: string | number | null | undefined): number { export const formatNumber = { fileSize: (bytes: number, decimals = 2): string => { - if (bytes === 0) return "0 Bytes"; + if (bytes === 0) return "0 B"; const k = 1024; const dm = decimals < 0 ? 0 : decimals; - const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i]; }, 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 0b4e464..fbe7514 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -1,5 +1,4 @@ -import { transformObject } from "core/utils"; - +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..351c46d 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,13 @@ 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"]), + }, + noErrorCodes: [404], + }), jsc( "param", s.object({ @@ -375,6 +393,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 +424,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 +452,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 +492,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 +515,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 +536,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 +558,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/connection-test-suite.ts b/app/src/data/connection/connection-test-suite.ts index af1eeba..aed28aa 100644 --- a/app/src/data/connection/connection-test-suite.ts +++ b/app/src/data/connection/connection-test-suite.ts @@ -1,6 +1,6 @@ import type { TestRunner } from "core/test"; import { Connection, type FieldSpec } from "./Connection"; -import { getPath } from "core/utils"; +import { getPath } from "bknd/utils"; import * as proto from "data/prototype"; import { createApp } from "App"; import type { MaybePromise } from "core/types"; 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 7b5c0d8..f416da6 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -1,16 +1,18 @@ -import { objectTransform } from "core/utils"; +import { objectTransform } from "bknd/utils"; 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/entities/EntityTypescript.ts b/app/src/data/entities/EntityTypescript.ts index 85255b9..b7e2b2c 100644 --- a/app/src/data/entities/EntityTypescript.ts +++ b/app/src/data/entities/EntityTypescript.ts @@ -1,6 +1,6 @@ import type { Entity, EntityManager, TEntityType } from "data/entities"; import type { EntityRelation } from "data/relations"; -import { autoFormatString } from "core/utils"; +import { autoFormatString } from "bknd/utils"; import { usersFields } from "auth/auth-entities"; import { mediaFields } from "media/media-entities"; diff --git a/app/src/data/entities/Result.ts b/app/src/data/entities/Result.ts index 0570aa6..2816efd 100644 --- a/app/src/data/entities/Result.ts +++ b/app/src/data/entities/Result.ts @@ -1,5 +1,5 @@ import { isDebug } from "core/env"; -import { pick } from "core/utils"; +import { pick } from "bknd/utils"; import type { Connection } from "data/connection"; import type { Compilable, diff --git a/app/src/data/entities/mutation/MutatorResult.ts b/app/src/data/entities/mutation/MutatorResult.ts index 551bd61..e9e876e 100644 --- a/app/src/data/entities/mutation/MutatorResult.ts +++ b/app/src/data/entities/mutation/MutatorResult.ts @@ -1,4 +1,4 @@ -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import type { Entity, EntityData } from "../Entity"; import type { EntityManager } from "../EntityManager"; import { Result, type ResultJSON, type ResultOptions } from "../Result"; diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 5f85d80..13554a6 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -1,5 +1,5 @@ import type { DB as DefaultDB, PrimaryFieldType } from "bknd"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { type EmitsEvents, EventManager } from "core/events"; import { type SelectQueryBuilder, sql } from "kysely"; import { InvalidSearchParamsException } from "../../errors"; diff --git a/app/src/data/entities/query/RepositoryResult.ts b/app/src/data/entities/query/RepositoryResult.ts index 7631f8f..85dc2eb 100644 --- a/app/src/data/entities/query/RepositoryResult.ts +++ b/app/src/data/entities/query/RepositoryResult.ts @@ -2,7 +2,7 @@ import type { Entity, EntityData } from "../Entity"; import type { EntityManager } from "../EntityManager"; import { Result, type ResultJSON, type ResultOptions } from "../Result"; import type { Compilable, SelectQueryBuilder } from "kysely"; -import { $console, ensureInt } from "core/utils"; +import { $console, ensureInt } from "bknd/utils"; export type RepositoryResultOptions = ResultOptions & { silent?: boolean; diff --git a/app/src/data/entities/query/WithBuilder.ts b/app/src/data/entities/query/WithBuilder.ts index 3f6dde3..5e9fd6a 100644 --- a/app/src/data/entities/query/WithBuilder.ts +++ b/app/src/data/entities/query/WithBuilder.ts @@ -1,4 +1,4 @@ -import { isObject } from "core/utils"; +import { isObject } from "bknd/utils"; import type { KyselyJsonFrom } from "data/relations/EntityRelation"; import type { RepoQuery } from "data/server/query"; diff --git a/app/src/data/fields/BooleanField.ts b/app/src/data/fields/BooleanField.ts index 1655a89..860dbe4 100644 --- a/app/src/data/fields/BooleanField.ts +++ b/app/src/data/fields/BooleanField.ts @@ -1,8 +1,7 @@ -import { omitKeys } from "core/utils"; +import { omitKeys, s } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; -import { s } from "bknd/utils"; export const booleanFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/fields/DateField.ts b/app/src/data/fields/DateField.ts index 0624986..20d152e 100644 --- a/app/src/data/fields/DateField.ts +++ b/app/src/data/fields/DateField.ts @@ -1,9 +1,7 @@ -import { dayjs } from "core/utils"; +import { dayjs, $console, s } from "bknd/utils"; import type { EntityManager } from "../entities"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; -import { $console } from "core/utils"; import type { TFieldTSType } from "data/entities/EntityTypescript"; -import { s } from "bknd/utils"; export const dateFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/fields/EnumField.ts b/app/src/data/fields/EnumField.ts index 306674c..5b2e10f 100644 --- a/app/src/data/fields/EnumField.ts +++ b/app/src/data/fields/EnumField.ts @@ -1,4 +1,4 @@ -import { omitKeys } from "core/utils"; +import { omitKeys } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field"; diff --git a/app/src/data/fields/JsonField.ts b/app/src/data/fields/JsonField.ts index 711767f..c54854b 100644 --- a/app/src/data/fields/JsonField.ts +++ b/app/src/data/fields/JsonField.ts @@ -1,4 +1,4 @@ -import { omitKeys } from "core/utils"; +import { omitKeys } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; diff --git a/app/src/data/fields/JsonSchemaField.ts b/app/src/data/fields/JsonSchemaField.ts index 76ad00a..fed47bf 100644 --- a/app/src/data/fields/JsonSchemaField.ts +++ b/app/src/data/fields/JsonSchemaField.ts @@ -1,5 +1,5 @@ import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema"; -import { objectToJsLiteral } from "core/utils"; +import { objectToJsLiteral } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; diff --git a/app/src/data/fields/NumberField.ts b/app/src/data/fields/NumberField.ts index 4f6e53c..b2e4516 100644 --- a/app/src/data/fields/NumberField.ts +++ b/app/src/data/fields/NumberField.ts @@ -2,8 +2,7 @@ import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; import type { TFieldTSType } from "data/entities/EntityTypescript"; -import { s } from "bknd/utils"; -import { omitKeys } from "core/utils"; +import { s, omitKeys } from "bknd/utils"; export const numberFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/fields/TextField.ts b/app/src/data/fields/TextField.ts index 94674fb..9567899 100644 --- a/app/src/data/fields/TextField.ts +++ b/app/src/data/fields/TextField.ts @@ -1,8 +1,7 @@ import type { EntityManager } from "data/entities"; -import { omitKeys } from "core/utils"; +import { omitKeys, s } from "bknd/utils"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, baseFieldConfigSchema } from "./Field"; -import { s } from "bknd/utils"; export const textFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/prototype/index.ts b/app/src/data/prototype/index.ts index 4f25aeb..06483f5 100644 --- a/app/src/data/prototype/index.ts +++ b/app/src/data/prototype/index.ts @@ -39,6 +39,9 @@ import { type PolymorphicRelationConfig, } from "data/relations"; +import type { MediaFields } from "media/AppMedia"; +import type { UsersFields } from "auth/AppAuth"; + type Options = { entity: { name: string; fields: Record> }; field_name: string; @@ -199,6 +202,18 @@ export function entity< return new Entity(name, _fields, config, type); } +type SystemEntities = { + users: UsersFields; + media: MediaFields; +}; + +export function systemEntity< + E extends keyof SystemEntities, + Fields extends Record>, +>(name: E, fields: Fields) { + return entity(name, fields as any); +} + export function relation(local: Local) { return { manyToOne: (foreign: Foreign, config?: ManyToOneRelationConfig) => { diff --git a/app/src/data/schema/constructor.ts b/app/src/data/schema/constructor.ts index 7742812..98cc2fa 100644 --- a/app/src/data/schema/constructor.ts +++ b/app/src/data/schema/constructor.ts @@ -1,4 +1,4 @@ -import { transformObject } from "core/utils"; +import { transformObject } from "bknd/utils"; import { Entity } from "data/entities"; import type { Field } from "data/fields"; import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema"; 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 f8ba0c0..cb4defe 100644 --- a/app/src/data/server/query.ts +++ b/app/src/data/server/query.ts @@ -1,13 +1,11 @@ -import { s } from "bknd/utils"; +import { s, isObject, $console } from "bknd/utils"; import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder"; -import { isObject, $console } from "core/utils"; -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( [ @@ -25,7 +23,7 @@ const stringArray = s.anyOf( if (v.includes(",")) { return v.split(","); } - return [v]; + return [v].filter(Boolean); } return []; }, @@ -80,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); }, @@ -97,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/flows/flows-schema.ts b/app/src/flows/flows-schema.ts index f430c6c..5d897c8 100644 --- a/app/src/flows/flows-schema.ts +++ b/app/src/flows/flows-schema.ts @@ -1,6 +1,5 @@ -import { transformObject } from "core/utils"; +import { transformObject, s } from "bknd/utils"; import { TaskMap, TriggerMap } from "flows"; -import { s } from "bknd/utils"; export const TASKS = { ...TaskMap, diff --git a/app/src/flows/flows/Execution.ts b/app/src/flows/flows/Execution.ts index 41d2166..7c8ef86 100644 --- a/app/src/flows/flows/Execution.ts +++ b/app/src/flows/flows/Execution.ts @@ -2,7 +2,7 @@ import { Event, EventManager, type ListenerHandler } from "core/events"; import type { EmitsEvents } from "core/events"; import type { Task, TaskResult } from "../tasks/Task"; import type { Flow } from "./Flow"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; export type TaskLog = TaskResult & { task: Task; diff --git a/app/src/flows/flows/Flow.ts b/app/src/flows/flows/Flow.ts index cf6a00b..2a1821a 100644 --- a/app/src/flows/flows/Flow.ts +++ b/app/src/flows/flows/Flow.ts @@ -1,4 +1,4 @@ -import { $console, transformObject } from "core/utils"; +import { $console, transformObject } from "bknd/utils"; import { type TaskMapType, TriggerMap } from "../index"; import type { Task } from "../tasks/Task"; import { Condition, TaskConnection } from "../tasks/TaskConnection"; diff --git a/app/src/flows/flows/executors/RuntimeExecutor.ts b/app/src/flows/flows/executors/RuntimeExecutor.ts index 55bf890..65888c7 100644 --- a/app/src/flows/flows/executors/RuntimeExecutor.ts +++ b/app/src/flows/flows/executors/RuntimeExecutor.ts @@ -1,5 +1,5 @@ import type { Task } from "../../tasks/Task"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; export class RuntimeExecutor { async run( diff --git a/app/src/flows/flows/triggers/EventTrigger.ts b/app/src/flows/flows/triggers/EventTrigger.ts index f17fd69..d1e5b82 100644 --- a/app/src/flows/flows/triggers/EventTrigger.ts +++ b/app/src/flows/flows/triggers/EventTrigger.ts @@ -1,8 +1,7 @@ import type { EventManager } from "core/events"; import type { Flow } from "../Flow"; import { Trigger } from "./Trigger"; -import { $console } from "core/utils"; -import { s } from "bknd/utils"; +import { $console, s } from "bknd/utils"; export class EventTrigger extends Trigger { override type = "event"; diff --git a/app/src/flows/tasks/TaskConnection.ts b/app/src/flows/tasks/TaskConnection.ts index 186eb28..d44b3f9 100644 --- a/app/src/flows/tasks/TaskConnection.ts +++ b/app/src/flows/tasks/TaskConnection.ts @@ -1,4 +1,4 @@ -import { objectCleanEmpty, uuid } from "core/utils"; +import { objectCleanEmpty, uuid } from "bknd/utils"; import { get } from "lodash-es"; import type { Task, TaskResult } from "./Task"; diff --git a/app/src/flows/tasks/presets/LogTask.ts b/app/src/flows/tasks/presets/LogTask.ts index 63b9677..05fc9f9 100644 --- a/app/src/flows/tasks/presets/LogTask.ts +++ b/app/src/flows/tasks/presets/LogTask.ts @@ -1,6 +1,5 @@ import { Task } from "../Task"; -import { $console } from "core/utils"; -import { s } from "bknd/utils"; +import { $console, s } from "bknd/utils"; export class LogTask extends Task { type = "log"; diff --git a/app/src/index.ts b/app/src/index.ts index 3a7b4d1..46902cd 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -35,10 +35,12 @@ 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 */ +export type { MaybePromise } from "core/types"; export { Exception, BkndError } from "core/errors"; export { isDebug, env } from "core/env"; export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; @@ -130,6 +132,7 @@ export { BaseIntrospector, Connection, customIntrospector, + DummyConnection, type FieldSpec, type IndexSpec, type DbFunctions, @@ -154,6 +157,7 @@ export { medium, make, entity, + systemEntity, relation, index, em, diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index a699d25..0971187 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,5 +1,5 @@ import type { AppEntity, FileUploadedEventData, StorageAdapter } from "bknd"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import type { Entity, EntityManager } from "data/entities"; import { Storage } from "media/storage/Storage"; import { Module } from "modules/Module"; @@ -9,6 +9,7 @@ import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema import { mediaFields } from "./media-entities"; import * as MediaPermissions from "media/media-permissions"; +export type MediaFields = typeof AppMedia.mediaFields; export type MediaFieldSchema = FieldSchema; declare module "bknd" { interface Media extends AppEntity, MediaFieldSchema {} 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/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index e364daa..893e25f 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -1,5 +1,5 @@ import { type EmitsEvents, EventManager } from "core/events"; -import { $console, isFile, detectImageDimensions } from "core/utils"; +import { $console, isFile, detectImageDimensions } from "bknd/utils"; import { isMimeType } from "media/storage/mime-types-tiny"; import * as StorageEvents from "./events"; import type { FileUploadedEventData } from "./events"; diff --git a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts index 96ec791..105dfef 100644 --- a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts @@ -1,7 +1,6 @@ -import { hash, pickHeaders } from "core/utils"; +import { hash, pickHeaders, s, parse } from "bknd/utils"; import type { FileBody, FileListObject, FileMeta } from "../../Storage"; import { StorageAdapter } from "../../StorageAdapter"; -import { s, parse } from "bknd/utils"; export const cloudinaryAdapterConfig = s.object( { 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..2948b5c 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,8 +1,8 @@ -import { mark, stripMark, $console, s, objectEach, transformObject } from "bknd/utils"; +import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils"; +import { DebugLogger } from "core/utils/DebugLogger"; 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 +144,7 @@ export class ModuleManager { server!: Hono; emgr!: EventManager; guard!: Guard; + mcp!: ModuleBuildContext["mcp"]; private _version: number = 0; private _built = false; @@ -271,6 +272,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 +290,7 @@ export class ModuleManager { guard: this.guard, flags: Module.ctx_flags, logger: this.logger, + mcp: this.mcp, }; return { @@ -702,7 +712,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/migrations.ts b/app/src/modules/migrations.ts index 3ce4ffb..e2834eb 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -1,4 +1,4 @@ -import { transformObject } from "core/utils"; +import { transformObject } from "bknd/utils"; import type { Kysely } from "kysely"; import { set } from "lodash-es"; 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..57af316 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -1,25 +1,36 @@ 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 }), + path: s.string({ default: "/api/system/mcp" }), }), - 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..25b20a0 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,54 @@ export class SystemController extends Controller { return this.app.modules.ctx(); } + register(app: App) { + app.server.route("/api/system", this.getController()); + const config = app.modules.get("server").config; + + if (!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]("MCP notification", message.params.message ?? message.params); + } + }); + + app.server.use( + mcpMiddleware({ + server: this._mcpServer, + sessionsEnabled: true, + debug: { + logLevel: "debug", + explainEndpoint: true, + }, + endpoint: { + path: config.mcp.path as any, + // @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 +132,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 +343,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 +359,7 @@ export class SystemController extends Controller { hono.get( "/ping", + mcpTool("system_ping"), describeRoute({ summary: "Ping the server", tags: ["system"], @@ -307,13 +369,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 +394,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 +410,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/plugins/cloudflare/image-optimization.plugin.ts b/app/src/plugins/cloudflare/image-optimization.plugin.ts index ab88161..cd6742f 100644 --- a/app/src/plugins/cloudflare/image-optimization.plugin.ts +++ b/app/src/plugins/cloudflare/image-optimization.plugin.ts @@ -19,22 +19,46 @@ const schema = s.partialObject({ type ImageOptimizationSchema = s.Static; export type CloudflareImageOptimizationOptions = { + /** + * The url to access the image optimization plugin + * @default /api/plugin/image/optimize + */ accessUrl?: string; + /** + * The path to resolve the image from + * @default /api/media/file + */ resolvePath?: string; + /** + * Whether to explain the image optimization schema + * @default false + */ explain?: boolean; + /** + * The default options to use + * @default {} + */ defaultOptions?: ImageOptimizationSchema; + /** + * The fixed options to use + * @default {} + */ fixedOptions?: ImageOptimizationSchema; + /** + * The cache control to use + * @default public, max-age=31536000, immutable + */ cacheControl?: string; }; export function cloudflareImageOptimization({ - accessUrl = "/_plugin/image/optimize", + accessUrl = "/api/plugin/image/optimize", resolvePath = "/api/media/file", explain = false, defaultOptions = {}, fixedOptions = {}, }: CloudflareImageOptimizationOptions = {}): AppPlugin { - const disallowedAccessUrls = ["/api", "/admin", "/_optimize"]; + const disallowedAccessUrls = ["/api", "/admin", "/api/plugin"]; if (disallowedAccessUrls.includes(accessUrl) || accessUrl.length < 2) { throw new Error(`Disallowed accessUrl: ${accessUrl}`); } diff --git a/app/src/ui/client/schema/actions.ts b/app/src/ui/client/schema/actions.ts index aa64003..3b8e619 100644 --- a/app/src/ui/client/schema/actions.ts +++ b/app/src/ui/client/schema/actions.ts @@ -1,6 +1,6 @@ import { type NotificationData, notifications } from "@mantine/notifications"; import type { Api } from "Api"; -import { ucFirst } from "core/utils"; +import { ucFirst } from "bknd/utils"; import type { ModuleConfigs } from "modules"; import type { ResponseObject } from "modules/ModuleApi"; import type { ConfigUpdateResponse } from "modules/server/SystemController"; diff --git a/app/src/ui/components/code/JsonViewer.tsx b/app/src/ui/components/code/JsonViewer.tsx index 923846b..26748fd 100644 --- a/app/src/ui/components/code/JsonViewer.tsx +++ b/app/src/ui/components/code/JsonViewer.tsx @@ -2,6 +2,37 @@ 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"; +import { formatNumber } from "bknd/utils"; + +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 text-wrap whitespace-wrap break-words", + numberValue: "text-sky-500 dark:text-sky-400 font-mono select-text", + 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 +42,9 @@ 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 ? 0 : JSON.stringify(json).length) : undefined; + const formattedSize = formatNumber.fileSize(size ?? 0); const showContext = size || title || showCopy; function onCopy() { @@ -31,9 +55,10 @@ export const JsonViewer = ({
{showContext && (
- {(title || size) && ( + {(title || size !== undefined) && (
- {title && {title}} {size && ({size} Bytes)} + {title && {title}}{" "} + {size !== undefined && ({formattedSize})}
)} {showCopy && ( @@ -43,30 +68,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/display/Icon.tsx b/app/src/ui/components/display/Icon.tsx index 65692c9..91181b4 100644 --- a/app/src/ui/components/display/Icon.tsx +++ b/app/src/ui/components/display/Icon.tsx @@ -13,6 +13,14 @@ const Warning = ({ className, ...props }: IconProps) => ( /> ); +const Err = ({ className, ...props }: IconProps) => ( + +); + export const Icon = { Warning, + Err, }; 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..c07e204 100644 --- a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx +++ b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx @@ -5,7 +5,13 @@ 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"; export type AnyOfFieldRootProps = { @@ -47,7 +53,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 +133,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..5aedfcc 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 = { @@ -108,7 +109,7 @@ export function Form< const formRef = useRef(null); useEffect(() => { - if (initialValues) { + if (initialValues && validateOn === "change") { validate(); } }, [initialValues]); @@ -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 81b4a92..333bba3 100644 --- a/app/src/ui/components/form/json-schema-form/utils.ts +++ b/app/src/ui/components/form/json-schema-form/utils.ts @@ -1,9 +1,9 @@ -import { autoFormatString, omitKeys } from "core/utils"; +import { autoFormatString, omitKeys } from "bknd/utils"; import { type Draft, Draft2019, type JsonSchema } from "json-schema-library"; import type { JSONSchema } from "json-schema-to-ts"; import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema"; -export { isEqual, getPath } from "core/utils/objects"; +export { isEqual, getPath } from "bknd/utils"; export function isNotDefined(value: any) { return value === null || value === undefined || value === ""; @@ -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/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 27a0142..908c49a 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -46,24 +46,73 @@ export type DropzoneRenderProps = { }; export type DropzoneProps = { + /** + * Get the upload info for a file + */ getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string }; + /** + * Handle the deletion of a file + */ handleDelete: (file: { path: string }) => Promise; + /** + * The initial items to display + */ initialItems?: FileState[]; - flow?: "start" | "end"; + /** + * Maximum number of media items that can be uploaded + */ maxItems?: number; + /** + * The allowed mime types + */ allowedMimeTypes?: string[]; + /** + * If true, the media item will be overwritten on entity media uploads if limit was reached + */ overwrite?: boolean; + /** + * If true, the media items will be uploaded automatically + */ autoUpload?: boolean; + /** + * Whether to add new items to the start or end of the list + * @default "start" + */ + flow?: "start" | "end"; + /** + * The on rejected callback + */ onRejected?: (files: FileWithPath[]) => void; + /** + * The on deleted callback + */ onDeleted?: (file: { path: string }) => void; + /** + * The on uploaded all callback + */ onUploadedAll?: (files: FileStateWithData[]) => void; + /** + * The on uploaded callback + */ onUploaded?: (file: FileStateWithData) => void; + /** + * The on clicked callback + */ onClick?: (file: FileState) => void; + /** + * The placeholder to use + */ placeholder?: { show?: boolean; text?: string; }; + /** + * The footer to render + */ footer?: ReactNode; + /** + * The children to render + */ children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode); }; diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index 2f695a9..46cef55 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -10,15 +10,38 @@ import { mediaItemsToFileStates } from "./helper"; import { useInViewport } from "@mantine/hooks"; export type DropzoneContainerProps = { + /** + * The initial items to display + * @default [] + */ initialItems?: MediaFieldSchema[] | false; + /** + * Whether to use infinite scrolling + * @default false + */ infinite?: boolean; + /** + * If given, the initial media items fetched will be from this entity + * @default undefined + */ entity?: { name: string; id: PrimaryFieldType; field: string; }; + /** + * The media config + * @default undefined + */ media?: Pick; + /** + * Query to filter the media items + */ query?: RepoQueryIn; + /** + * Whether to use a random filename + * @default false + */ randomFilename?: boolean; } & Omit, "initialItems">; diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index 9018e29..b6f704a 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -1,6 +1,6 @@ import { useClickOutside, useHotkeys } from "@mantine/hooks"; import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; -import { clampNumber } from "core/utils/numbers"; +import { transformObject, clampNumber } from "bknd/utils"; import { throttle } from "lodash-es"; import { ScrollArea } from "radix-ui"; import { @@ -19,14 +19,20 @@ import { appShellStore } from "ui/store"; import { useLocation } from "wouter"; export function Root({ children }: { children: React.ReactNode }) { - const sidebarWidth = appShellStore((store) => store.sidebarWidth); + const sidebarWidths = appShellStore((store) => store.sidebars); + const style = transformObject(sidebarWidths, (value) => value.width); return (
[ + `--sidebar-width-${key}`, + `${value}px`, + ]), + )} > {children}
@@ -73,7 +79,7 @@ export function Content({ children, center }: { children: React.ReactNode; cente
@@ -97,10 +103,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,53 +135,80 @@ export function Sidebar({ children }) { return ( <> + {handle === "left" && ( + + )} - + {handle === "right" && ( + + )}
); } -const SidebarResize = () => { - const setSidebarWidth = appShellStore((store) => store.setSidebarWidth); +const SidebarResize = ({ + name = "default", + handle = "right", + sidebarRef, + minWidth = 250, + maxWidth = window.innerWidth * 0.5, +}: { + name?: string; + handle?: "right" | "left"; + sidebarRef: React.RefObject; + minWidth?: number; + maxWidth?: number; +}) => { + const setSidebarWidth = appShellStore((store) => store.setSidebarWidth(name)); const [isResizing, setIsResizing] = useState(false); - const [startX, setStartX] = useState(0); - const [startWidth, setStartWidth] = useState(0); + const [start, setStart] = useState(0); + const [startWidth, setStartWidth] = useState(sidebarRef.current?.offsetWidth ?? 0); const handleMouseDown = (e: React.MouseEvent) => { e.preventDefault(); setIsResizing(true); - setStartX(e.clientX); - setStartWidth( - Number.parseInt( - getComputedStyle(document.getElementById("app-shell")!) - .getPropertyValue("--sidebar-width") - .replace("px", ""), - ), - ); + setStart(e.clientX); + setStartWidth(sidebarRef.current?.offsetWidth ?? 0); }; const handleMouseMove = (e: MouseEvent) => { if (!isResizing) return; - const diff = e.clientX - startX; - const newWidth = clampNumber(startWidth + diff, 250, window.innerWidth * 0.5); + const diff = handle === "right" ? e.clientX - start : start - e.clientX; + const newWidth = clampNumber(startWidth + diff, minWidth, maxWidth); setSidebarWidth(newWidth); }; @@ -179,10 +226,11 @@ const SidebarResize = () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; - }, [isResizing, startX, startWidth]); + }, [isResizing, start, startWidth, minWidth, maxWidth]); return (
{ ); }; +export function MaxHeightContainer(props: ComponentPropsWithoutRef<"div">) { + const scrollRef = useRef>(null); + const [offset, setOffset] = useState(0); + const [height, setHeight] = useState(window.innerHeight); + + function updateHeaderHeight() { + if (scrollRef.current) { + // get offset to top of window + const offset = scrollRef.current.getBoundingClientRect().top; + const height = window.innerHeight; + setOffset(offset); + setHeight(height); + } + } + + useEffect(updateHeaderHeight, []); + + if (typeof window !== "undefined") { + window.addEventListener("resize", throttle(updateHeaderHeight, 500)); + } + + return ( +
+ {props.children} +
+ ); +} + export function Scrollable({ children, initialOffset = 64, @@ -346,7 +422,9 @@ export function Scrollable({ function updateHeaderHeight() { if (scrollRef.current) { - setOffset(scrollRef.current.offsetTop); + // get offset to top of window + const offset = scrollRef.current.getBoundingClientRect().top; + setOffset(offset); } } diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index 629d537..7e6036f 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -25,6 +25,7 @@ import { NavLink } from "./AppShell"; import { autoFormatString } from "core/utils"; import { appShellStore } from "ui/store"; import { getVersion } from "core/env"; +import { McpIcon } from "ui/routes/tools/mcp/components/mcp-icon"; export function HeaderNavigation() { const [location, navigate] = useLocation(); @@ -105,9 +106,9 @@ export function HeaderNavigation() { ); } -function SidebarToggler() { - const toggle = appShellStore((store) => store.toggleSidebar); - const open = appShellStore((store) => store.sidebarOpen); +function SidebarToggler({ name = "default" }: { name?: string }) { + const toggle = appShellStore((store) => store.toggleSidebar(name)); + const open = appShellStore((store) => store.sidebars[name]?.open); return ; } @@ -132,7 +133,7 @@ export function Header({ hasSidebar = true }) {
- +
@@ -172,6 +173,14 @@ function UserMenu() { }, ]; + if (config.server.mcp.enabled) { + items.push({ + label: "MCP", + onClick: () => navigate("/tools/mcp"), + icon: McpIcon, + }); + } + if (config.auth.enabled) { if (!auth.user) { items.push({ label: "Login", onClick: handleLogin, icon: IconUser }); diff --git a/app/src/ui/main.css b/app/src/ui/main.css index 280e49d..755af31 100644 --- a/app/src/ui/main.css +++ b/app/src/ui/main.css @@ -18,7 +18,7 @@ --color-success-foreground: var(--color-green-800); --color-info: var(--color-blue-100); --color-info-foreground: var(--color-blue-800); - + --color-resize: var(--color-blue-300); @mixin light { @@ -74,7 +74,7 @@ @apply bg-background text-primary overflow-hidden h-dvh w-dvw; ::selection { - @apply bg-muted; + @apply bg-primary/15; } input { @@ -115,3 +115,10 @@ body, @apply bg-primary/25; } } + +@utility debug { + @apply border-red-500 border; +} +@utility debug-blue { + @apply border-blue-500 border; +} diff --git a/app/src/ui/main.tsx b/app/src/ui/main.tsx index 07e6540..ea561e4 100644 --- a/app/src/ui/main.tsx +++ b/app/src/ui/main.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom/client"; import Admin from "./Admin"; -import "./main.css"; +//import "./main.css"; import "./styles.css"; function render() { diff --git a/app/src/ui/routes/data/data.$entity.index.tsx b/app/src/ui/routes/data/data.$entity.index.tsx index 09a604e..ff3c057 100644 --- a/app/src/ui/routes/data/data.$entity.index.tsx +++ b/app/src/ui/routes/data/data.$entity.index.tsx @@ -124,7 +124,7 @@ function DataEntityListImpl({ params }) { } > - {entity.label} + {entity.label}
diff --git a/app/src/ui/routes/index.tsx b/app/src/ui/routes/index.tsx index 3cf7e7b..f260485 100644 --- a/app/src/ui/routes/index.tsx +++ b/app/src/ui/routes/index.tsx @@ -12,6 +12,7 @@ import { FlashMessage } from "ui/modules/server/FlashMessage"; import { AuthRegister } from "ui/routes/auth/auth.register"; import { BkndModalsProvider } from "ui/modals"; import { useBkndWindowContext } from "ui/client"; +import ToolsRoutes from "./tools"; // @ts-ignore const TestRoutes = lazy(() => import("./test")); @@ -69,6 +70,11 @@ export function Routes({ + + + + + diff --git a/app/src/ui/routes/media/_media.root.tsx b/app/src/ui/routes/media/_media.root.tsx index 971df64..d54bfc6 100644 --- a/app/src/ui/routes/media/_media.root.tsx +++ b/app/src/ui/routes/media/_media.root.tsx @@ -1,10 +1,8 @@ -import { IconAlertHexagon } from "@tabler/icons-react"; import { TbSettings } from "react-icons/tb"; import { useBknd } from "ui/client/BkndProvider"; import { IconButton } from "ui/components/buttons/IconButton"; import { Icon } from "ui/components/display/Icon"; import { Link } from "ui/components/wouter/Link"; -import { Media } from "ui/elements"; import { useBrowserTitle } from "ui/hooks/use-browser-title"; import * as AppShell from "ui/layouts/AppShell/AppShell"; diff --git a/app/src/ui/routes/test/index.tsx b/app/src/ui/routes/test/index.tsx index d099a13..71bb87f 100644 --- a/app/src/ui/routes/test/index.tsx +++ b/app/src/ui/routes/test/index.tsx @@ -26,6 +26,7 @@ import SchemaTest from "./tests/schema-test"; import SortableTest from "./tests/sortable-test"; import { SqlAiTest } from "./tests/sql-ai-test"; import Themes from "./tests/themes"; +import ErrorBoundary from "ui/components/display/ErrorBoundary"; const tests = { DropdownTest, @@ -88,7 +89,9 @@ function TestRoot({ children }) {
- {children} + + {children} + ); } diff --git a/app/src/ui/routes/test/tests/json-schema-form3.tsx b/app/src/ui/routes/test/tests/json-schema-form3.tsx index be2bfb0..401ab1f 100644 --- a/app/src/ui/routes/test/tests/json-schema-form3.tsx +++ b/app/src/ui/routes/test/tests/json-schema-form3.tsx @@ -1,6 +1,7 @@ import type { JSONSchema } from "json-schema-to-ts"; import { useBknd } from "ui/client/bknd"; import { Button } from "ui/components/buttons/Button"; +import { s } from "bknd/utils"; import { AnyOf, AnyOfField, @@ -73,7 +74,31 @@ export default function JsonSchemaForm3() { return (
-
+ {/* */} + + {/* console.log("change", data)} diff --git a/app/src/ui/routes/tools/index.tsx b/app/src/ui/routes/tools/index.tsx new file mode 100644 index 0000000..3bfc6b0 --- /dev/null +++ b/app/src/ui/routes/tools/index.tsx @@ -0,0 +1,16 @@ +import { Empty } from "ui/components/display/Empty"; +import { Route } from "wouter"; +import ToolsMcp from "./mcp/mcp"; + +export default function ToolsRoutes() { + return ( + <> + + + + ); +} + +function ToolsIndex() { + return ; +} diff --git a/app/src/ui/routes/tools/mcp/components/mcp-icon.tsx b/app/src/ui/routes/tools/mcp/components/mcp-icon.tsx new file mode 100644 index 0000000..02c6504 --- /dev/null +++ b/app/src/ui/routes/tools/mcp/components/mcp-icon.tsx @@ -0,0 +1,15 @@ +export const McpIcon = () => ( + + ModelContextProtocol + + + +); diff --git a/app/src/ui/routes/tools/mcp/hooks/use-mcp-client.ts b/app/src/ui/routes/tools/mcp/hooks/use-mcp-client.ts new file mode 100644 index 0000000..5ad9d4b --- /dev/null +++ b/app/src/ui/routes/tools/mcp/hooks/use-mcp-client.ts @@ -0,0 +1,16 @@ +import { McpClient, type McpClientConfig } from "jsonv-ts/mcp"; +import { useBknd } from "ui/client/bknd"; + +const clients = new Map(); + +export function getClient(opts: McpClientConfig) { + if (!clients.has(JSON.stringify(opts))) { + clients.set(JSON.stringify(opts), new McpClient(opts)); + } + return clients.get(JSON.stringify(opts))!; +} + +export function useMcpClient() { + const { config } = useBknd(); + return getClient({ url: window.location.origin + config.server.mcp.path }); +} diff --git a/app/src/ui/routes/tools/mcp/mcp.tsx b/app/src/ui/routes/tools/mcp/mcp.tsx new file mode 100644 index 0000000..c06668b --- /dev/null +++ b/app/src/ui/routes/tools/mcp/mcp.tsx @@ -0,0 +1,79 @@ +import * as AppShell from "ui/layouts/AppShell/AppShell"; +import { useMcpStore } from "./state"; +import * as Tools from "./tools"; +import { TbWorld } from "react-icons/tb"; +import { McpIcon } from "./components/mcp-icon"; +import { useBknd } from "ui/client/bknd"; +import { Empty } from "ui/components/display/Empty"; +import { Button } from "ui/components/buttons/Button"; +import { appShellStore } from "ui/store"; +import { useBrowserTitle } from "ui/hooks/use-browser-title"; + +export default function ToolsMcp() { + useBrowserTitle(["MCP UI"]); + + const { config, options } = useBknd(); + const feature = useMcpStore((state) => state.feature); + const setFeature = useMcpStore((state) => state.setFeature); + const content = useMcpStore((state) => state.content); + const openSidebar = appShellStore((store) => store.toggleSidebar("default")); + const mcpPath = config.server.mcp.path; + + if (!config.server.mcp.enabled) { + return ( + + ); + } + + return ( +
+ +
+ + + MCP UI + +
+ +
+ + {window.location.origin + mcpPath} + +
+
+
+
+ +
+ + setFeature("tools")} /> + setFeature("resources")} + > +
+ Resources +
+
+
+ {feature === "tools" && } + + {!content && ( + + + + )} +
+
+ ); +} diff --git a/app/src/ui/routes/tools/mcp/state.ts b/app/src/ui/routes/tools/mcp/state.ts new file mode 100644 index 0000000..877f324 --- /dev/null +++ b/app/src/ui/routes/tools/mcp/state.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; +import { combine } from "zustand/middleware"; + +import type { ToolJson } from "jsonv-ts/mcp"; + +const FEATURES = ["tools", "resources"] as const; +export type Feature = (typeof FEATURES)[number]; + +export const useMcpStore = create( + combine( + { + tools: [] as ToolJson[], + feature: "tools" as Feature | null, + content: null as ToolJson | null, + history: [] as { type: "request" | "response"; data: any }[], + historyLimit: 50, + historyVisible: false, + }, + (set) => ({ + setTools: (tools: ToolJson[]) => set({ tools }), + setFeature: (feature: Feature) => set({ feature }), + setContent: (content: ToolJson | null) => set({ content }), + addHistory: (type: "request" | "response", data: any) => + set((state) => ({ + history: [{ type, data }, ...state.history.slice(0, state.historyLimit - 1)], + })), + setHistoryLimit: (limit: number) => set({ historyLimit: limit }), + setHistoryVisible: (visible: boolean) => set({ historyVisible: visible }), + }), + ), +); diff --git a/app/src/ui/routes/tools/mcp/tools.tsx b/app/src/ui/routes/tools/mcp/tools.tsx new file mode 100644 index 0000000..d439fc1 --- /dev/null +++ b/app/src/ui/routes/tools/mcp/tools.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useRef, useState, useTransition } from "react"; +import { getTemplate } from "./utils"; +import { useMcpStore } from "./state"; +import { AppShell } from "ui/layouts/AppShell"; +import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb"; +import { IconButton } from "ui/components/buttons/IconButton"; +import { JsonViewer, JsonViewerTabs, type JsonViewerTabsRef } from "ui/components/code/JsonViewer"; +import { twMerge } from "ui/elements/mocks/tailwind-merge"; +import { Field, Form } from "ui/components/form/json-schema-form"; +import { Button } from "ui/components/buttons/Button"; +import * as Formy from "ui/components/form/Formy"; +import { appShellStore } from "ui/store"; +import { Icon } from "ui/components/display/Icon"; +import { useMcpClient } from "./hooks/use-mcp-client"; + +export function Sidebar({ open, toggle }) { + const client = useMcpClient(); + const closeSidebar = appShellStore((store) => store.closeSidebar("default")); + const tools = useMcpStore((state) => state.tools); + const setTools = useMcpStore((state) => state.setTools); + const setContent = useMcpStore((state) => state.setContent); + const content = useMcpStore((state) => state.content); + const [loading, setLoading] = useState(false); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + + const handleRefresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await client.listTools(); + if (res) setTools(res.tools); + } catch (e) { + console.error(e); + setError(String(e)); + } + setLoading(false); + }, []); + + useEffect(() => { + handleRefresh(); + }, []); + + return ( + ( +
+ {error && } + + {tools.length} + + +
+ )} + > +
+ setQuery(e.target.value)} + autoCapitalize="none" + /> + +
+
+ ); +} + +export function Content() { + const content = useMcpStore((state) => state.content); + const addHistory = useMcpStore((state) => state.addHistory); + const [payload, setPayload] = useState(getTemplate(content?.inputSchema)); + const [result, setResult] = useState(null); + const historyVisible = useMcpStore((state) => state.historyVisible); + const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible); + const client = useMcpClient(); + const jsonViewerTabsRef = useRef(null); + const hasInputSchema = + content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0; + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + setPayload(getTemplate(content?.inputSchema)); + setResult(null); + }, [content]); + + const handleSubmit = useCallback(async () => { + if (!content?.name) return; + const request = { + name: content.name, + arguments: payload, + }; + startTransition(async () => { + addHistory("request", request); + const res = await client.callTool(request); + if (res) { + setResult(res); + addHistory("response", res); + jsonViewerTabsRef.current?.setSelected("Result"); + } + }); + }, [payload]); + + if (!content) return null; + + let readableResult = result; + try { + readableResult = result + ? (result as any).content?.[0].text + ? JSON.parse((result as any).content[0].text) + : result + : null; + } catch (e) {} + + return ( + { + setPayload(value); + }} + onSubmit={handleSubmit} + > + + setHistoryVisible(!historyVisible)} + /> + + + } + > + + + Tools / + {" "} + {content?.name} + + +
+
+ +
+

{content?.description}

+ + {hasInputSchema && } + +
+
+
+ {historyVisible && ( + + + + )} +
+ + ); +} + +const History = () => { + const history = useMcpStore((state) => state.history.slice(0, 50)); + + return ( + <> + History + +
+ {history.map((item, i) => ( + + ))} +
+
+ + ); +}; diff --git a/app/src/ui/routes/tools/mcp/utils.ts b/app/src/ui/routes/tools/mcp/utils.ts new file mode 100644 index 0000000..dfac3bc --- /dev/null +++ b/app/src/ui/routes/tools/mcp/utils.ts @@ -0,0 +1,8 @@ +import { Draft2019 } from "json-schema-library"; + +export function getTemplate(schema: object) { + if (!schema || schema === undefined || schema === null) return undefined; + + const lib = new Draft2019(schema); + return lib.getTemplate(undefined, schema); +} diff --git a/app/src/ui/store/appshell.ts b/app/src/ui/store/appshell.ts index d58c867..27c6d43 100644 --- a/app/src/ui/store/appshell.ts +++ b/app/src/ui/store/appshell.ts @@ -1,23 +1,73 @@ import { create } from "zustand"; import { combine, persist } from "zustand/middleware"; +type SidebarState = { + open: boolean; + width: number; +}; + export const appShellStore = create( persist( combine( { - sidebarOpen: false as boolean, - sidebarWidth: 350 as number, + sidebars: { + default: { + open: false, + width: 350, + }, + } as Record, }, (set) => ({ - toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), - closeSidebar: () => set({ sidebarOpen: false }), - openSidebar: () => set({ sidebarOpen: true }), - setSidebarWidth: (width: number) => set({ sidebarWidth: width }), - resetSidebarWidth: () => set({ sidebarWidth: 350 }), + toggleSidebar: (name: string) => () => + set((state) => { + const sidebar = state.sidebars[name]; + if (!sidebar) return state; + return { + sidebars: { + ...state.sidebars, + [name]: { ...sidebar, open: !sidebar.open }, + }, + }; + }), + closeSidebar: (name: string) => () => + set((state) => { + const sidebar = state.sidebars[name]; + if (!sidebar) return state; + return { + sidebars: { ...state.sidebars, [name]: { ...sidebar, open: false } }, + }; + }), + setSidebarWidth: (name: string) => (width: number) => + set((state) => { + const sidebar = state.sidebars[name]; + if (!sidebar) + return { sidebars: { ...state.sidebars, [name]: { open: false, width } } }; + return { sidebars: { ...state.sidebars, [name]: { ...sidebar, width } } }; + }), + resetSidebarWidth: (name: string) => + set((state) => { + const sidebar = state.sidebars[name]; + if (!sidebar) return state; + return { sidebars: { ...state.sidebars, [name]: { ...sidebar, width: 350 } } }; + }), + + setSidebarState: (name: string, update: SidebarState) => + set((state) => ({ sidebars: { ...state.sidebars, [name]: update } })), }), ), { name: "appshell", + version: 1, + migrate: () => { + return { + sidebars: { + default: { + open: false, + width: 350, + }, + }, + }; + }, }, ), ); diff --git a/app/tsconfig.json b/app/tsconfig.json index a40d88a..55264d4 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -32,12 +32,8 @@ "*": ["./src/*"], "bknd": ["./src/index.ts"], "bknd/utils": ["./src/core/utils/index.ts"], - "bknd/core": ["./src/core/index.ts"], "bknd/adapter": ["./src/adapter/index.ts"], - "bknd/client": ["./src/ui/client/index.ts"], - "bknd/data": ["./src/data/index.ts"], - "bknd/media": ["./src/media/index.ts"], - "bknd/auth": ["./src/auth/index.ts"] + "bknd/client": ["./src/ui/client/index.ts"] } }, "include": [ diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 1274d21..bee9219 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -7,7 +7,7 @@ import type { Connection } from "./src/data/connection/Connection"; import { __bknd } from "modules/ModuleManager"; import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection"; import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection"; -import { $console } from "core/utils"; +import { $console } from "core/utils/console"; import { createClient } from "@libsql/client"; registries.media.register("local", StorageLocalAdapter); diff --git a/bun.lock b/bun.lock index 7d6429f..d9765f6 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "app": { "name": "bknd", - "version": "0.16.1", + "version": "0.17.0-rc.1", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -35,7 +35,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.3.2", + "jsonv-ts": "0.8.2", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -1232,7 +1232,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + "@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -2516,7 +2516,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.3.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-wGKLo0naUzgOCa2BgtlKZlF47po7hPjGXqDZK2lOoJ/4sE1lb4fMvf0YJrRghqfwg9QNtWz01xALr+F0QECYag=="], + "jsonv-ts": ["jsonv-ts@0.8.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-1Z7+maCfoGGqBPu5vN8rU9gIsW7OatYmn+STBTPkybbtNqeMzAoJDDrXHjsZ89x5dPH9W+OgMpNLtN0ouwiMYg=="], "jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="], @@ -3832,6 +3832,8 @@ "@bknd/plasmic/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "@bknd/postgres/@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + "@bknd/postgres/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "@bknd/sqlocal/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], @@ -4076,7 +4078,7 @@ "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], - "@types/bun/bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], + "@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], "@typescript-eslint/experimental-utils/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="], @@ -4682,6 +4684,8 @@ "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="], + "@bknd/postgres/@types/bun/bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], + "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], "@cloudflare/vitest-pool-workers/miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], diff --git a/docs/app/[[...slug]]/page.tsx b/docs/app/[[...slug]]/page.tsx index 920e47f..42ab098 100644 --- a/docs/app/[[...slug]]/page.tsx +++ b/docs/app/[[...slug]]/page.tsx @@ -13,10 +13,12 @@ export default async function Page(props: { if (!page) notFound(); const MDXContent = page.data.body; + // in case a page exports a custom toc + const toc = (page.data as any).custom_toc ?? page.data.toc; return ( s.toLowerCase().replace(/ /g, "-"); +export const indent = (s: string, indent = 2) => s.replace(/^/gm, " ".repeat(indent)); + +export function McpTool({ tool }: { tool: ReturnType }) { + return ( +
+ + {tool.name} + +

{tool.description}

+ + +
+ ); +} + +const getType = (value: JSONSchemaDefinition) => { + if (value === undefined || value === null) { + return "any"; + } + + if (value.type) { + if (Array.isArray(value.type)) { + return value.type.join(" | "); + } + return value.type; + } + + if ("anyOf" in value) { + return value.anyOf.map(getType).join(" | "); + } + + if ("oneOf" in value) { + return value.oneOf.map(getType).join(" | "); + } + + return "any"; +}; + +export function JsonSchemaTypeTable({ schema }: { schema: JSONSchemaDefinition }) { + const properties = schema.properties ?? {}; + const required = schema.required ?? []; + const getTypeDescription = (value: any) => + JSON.stringify( + { + ...value, + $target: undefined, + }, + null, + 2, + ); + + return Object.keys(properties).length > 0 ? ( + [ + key, + { + description: value.description, + typeDescription: ( + + ), + type: getType(value), + default: value.default !== undefined ? JSON.stringify(value.default) : undefined, + required: required.includes(key), + }, + ]), + )} + /> + ) : null; +} diff --git a/docs/components/misc/Wrapper.tsx b/docs/components/misc/Wrapper.tsx new file mode 100644 index 0000000..d72c875 --- /dev/null +++ b/docs/components/misc/Wrapper.tsx @@ -0,0 +1,16 @@ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "react"; + +export function Wrapper(props: HTMLAttributes) { + return ( +
+ {props.children} +
+ ); +} diff --git a/docs/content/docs/(documentation)/extending/plugins.mdx b/docs/content/docs/(documentation)/extending/plugins.mdx new file mode 100644 index 0000000..e18606c --- /dev/null +++ b/docs/content/docs/(documentation)/extending/plugins.mdx @@ -0,0 +1,211 @@ +--- +title: Plugins +tags: ["documentation"] +--- +import { TypeTable } from 'fumadocs-ui/components/type-table'; + + +bknd allows you to extend its functionality by creating plugins. These allows to hook into the app lifecycle and to provide a data structure that is guaranteed to be merged. A plugin is a function that takes in an instance of `App` and returns the following structure: + + + +## Creating a simple plugin + +To create a simple plugin which guarantees an entity `pages` to be available and an additioanl endpoint to render a html list of pages, you can create it as follows: + +```tsx title="myPagesPlugin.tsx" +/** @jsxImportSource hono/jsx */ +import { type App, type AppPlugin, em, entity, text } from "bknd"; + +export const myPagesPlugin: AppPlugin = (app) => ({ + name: "my-pages-plugin", + // define the schema of the plugin + // this will always be merged into the app's schema + schema: () => em({ + pages: entity("pages", { + title: text(), + content: text(), + }), + }), + // execute code after the app is built + onBuilt: () => { + // register a new endpoint, make sure that you choose an endpoint that is reachable for bknd + app.server.get("/my-pages", async (c) => { + const { data: pages } = await app.em.repo("pages").findMany({}); + return c.html( + +

Pages: {pages.length}

+
    + {pages.map((page: any) => ( +
  • {page.title}
  • + ))} +
+ , + ); + }); + }, +}); + +``` + +And then register it in your `bknd.config.ts` file: + +```typescript +import type { BkndConfig } from "bknd/adapter"; +import { myPagesPlugin } from "./myPagesPlugin"; + +export default { + options: { + plugins: [myPagesPlugin], + } +} satisfies BkndConfig; +``` + +The schema returned from the plugin will be merged into the schema of the app. + + +## Built-in plugins + +bknd comes with a few built-in plugins that you can use. + +### `syncTypes` + +A simple plugin that writes down the TypeScript types of the data schema on boot and each build. The output is equivalent to running `npx bknd types`. + +```typescript title="bknd.config.ts" +import { syncTypes } from "bknd/plugins"; +import { writeFile } from "node:fs/promises"; + +export default { + options: { + plugins: [ + syncTypes({ + // whether to enable the plugin, make sure to disable in production + enabled: true, + // your writing function (required) + write: async (et) => { + await writeFile("bknd-types.d.ts", et.toString(), "utf-8"); + } + }), + ] + }, +} satisfies BkndConfig; +``` + +### `syncConfig` + +A simple plugin that writes down the app configuration on boot and each build. + +```typescript title="bknd.config.ts" +import { syncConfig } from "bknd/plugins"; +import { writeFile } from "node:fs/promises"; + +export default { + options: { + plugins: [ + syncConfig({ + // whether to enable the plugin, make sure to disable in production + enabled: true, + // your writing function (required) + write: async (config) => { + await writeFile("config.json", JSON.stringify(config, null, 2), "utf-8"); + }, + }), + ] + }, +} satisfies BkndConfig; +``` + +### `showRoutes` + +A simple plugin that logs the routes of your app in the console. + +```typescript title="bknd.config.ts" +import { showRoutes } from "bknd/plugins"; + +export default { + options: { + plugins: [ + showRoutes({ + // whether to show the routes only once (on first build) + once: true + }) + ], + }, +} satisfies BkndConfig; +``` + +### `cloudflareImageOptimization` + +A plugin that add Cloudflare Image Optimization to your app's media storage. + +```typescript title="bknd.config.ts" +import { cloudflareImageOptimization } from "bknd/plugins"; + +export default { + options: { + plugins: [ + cloudflareImageOptimization({ + // the url to access the image optimization plugin + accessUrl: "/api/plugin/image/optimize", + // the path to resolve the image from, defaults to `/api/media/file` + resolvePath: "/api/media/file", + // for example, you may want to have default option to limit to a width of 1000px + defaultOptions: { + width: 1000, + } + }) + ], + }, +} satisfies BkndConfig; +``` + +Here is a break down of all configuration options: + + + +When enabled, you can now access your images at your configured `accessUrl`. For example, if you have a media file at `/api/media/file/image.jpg`, you can access the optimized image at `/api/plugin/image/optimize/image.jpg` for optimization. + +Now you can add query parameters for the transformations, e.g. `?width=1000&height=1000`. + + + + + + + diff --git a/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx b/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx index 257d2a7..daeadea 100644 --- a/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx +++ b/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx @@ -123,9 +123,8 @@ import { serve } from "bknd/adapter/cloudflare"; export default serve({ // ... onBuilt: async (app) => { - // [!code highlight] app.server.get("/hello", (c) => c.json({ hello: "world" })); // [!code highlight] - }, // [!code highlight] + }, }); ``` @@ -141,7 +140,6 @@ With the Cloudflare Workers adapter, you're being offered to 4 modes to choose f | `fresh` | On every request, the configuration gets refetched, app built and then served. | Ideal if you don't want to deal with eviction, KV or Durable Objects. | | `warm` | It tries to keep the built app in memory for as long as possible, and rebuilds if evicted. | Better response times, should be the default choice. | | `cache` | The configuration is fetched from KV to reduce the initial roundtrip to the database. | Generally faster response times with irregular access patterns. | -| `durable` | The bknd app is ran inside a Durable Object and can be configured to stay alive. | Slowest boot time, but fastest responses. Can be kept alive for as long as you want, giving similar response times as server instances. | ### Modes: `fresh` and `warm` @@ -172,76 +170,6 @@ export default serve({ }); ``` -### Mode: `durable` (advanced) - -To use the `durable` mode, you have to specify the Durable Object to extract from your -environment, and additionally export the `DurableBkndApp` class: - -```ts -import { serve, DurableBkndApp } from "bknd/adapter/cloudflare"; - -export { DurableBkndApp }; -export default serve({ - // ... - mode: "durable", - bindings: ({ env }) => ({ dobj: env.DOBJ }), - keepAliveSeconds: 60, // optional -}); -``` - -Next, you need to define the Durable Object in your `wrangler.toml` file (refer to the [Durable -Objects](https://developers.cloudflare.com/durable-objects/) documentation): - -```toml -[[durable_objects.bindings]] -name = "DOBJ" -class_name = "DurableBkndApp" - -[[migrations]] -tag = "v1" -new_classes = ["DurableBkndApp"] -``` - -Since the communication between the Worker and Durable Object is serialized, the `onBuilt` -property won't work. To use it (e.g. to specify special routes), you need to extend from the -`DurableBkndApp`: - -```ts -import type { App } from "bknd"; -import { serve, DurableBkndApp } from "bknd/adapter/cloudflare"; - -export default serve({ - // ... - mode: "durable", - bindings: ({ env }) => ({ dobj: env.DOBJ }), - keepAliveSeconds: 60, // optional -}); - -export class CustomDurableBkndApp extends DurableBkndApp { - async onBuilt(app: App) { - app.modules.server.get("/custom/endpoint", (c) => c.text("Custom")); - } -} -``` - -In case you've already deployed your Worker, the deploy command may complain about a new class -being used. To fix this issue, you need to add a "rename migration": - -```toml -[[durable_objects.bindings]] -name = "DOBJ" -class_name = "CustomDurableBkndApp" - -[[migrations]] -tag = "v1" -new_classes = ["DurableBkndApp"] - -[[migrations]] -tag = "v2" -renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}] -deleted_classes = ["DurableBkndApp"] -``` - ## D1 Sessions (experimental) D1 now supports to enable [global read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/). This allows to reduce latency by reading from the closest region. In order for this to work, D1 has to be started from a bookmark. You can enable this behavior on bknd by setting the `d1.session` property: @@ -272,3 +200,83 @@ If bknd is used in a stateful user context (like in a browser), it'll automatica ```bash curl -H "x-cf-d1-session: " ... ``` + +## Filesystem access with Vite Plugin +The [Cloudflare Vite Plugin](https://developers.cloudflare.com/workers/vite-plugin/) allows to use Vite with Miniflare to emulate the Cloudflare Workers runtime. This is great, however, `unenv` disables any Node.js APIs that aren't supported, including the `fs` module. If you want to use plugins such as [`syncTypes`](/extending/plugins#synctypes), this will cause issues. + +To fix this, bknd exports a Vite plugin that provides filesystem access during development. You can use it by adding the following to your `vite.config.ts` file: + +```ts +import { devFsVitePlugin } from "bknd/adapter/cloudflare/vite"; + +export default defineConfig({ + plugins: [devFsVitePlugin()], // [!code highlight] +}); +``` + +Now to use this polyfill, you can use the `devFsWrite` function to write files to the filesystem. + +```ts +import { devFsWrite } from "bknd/adapter/cloudflare/vite"; // [!code highlight] +import { syncTypes } from "bknd/plugins"; + +export default { + options: { + plugins: [ + syncTypes({ + write: async (et) => { + await devFsWrite("bknd-types.d.ts", et.toString()); // [!code highlight] + } + }), + ] + }, +} satisfies BkndConfig; +``` + +## Cloudflare Bindings in CLI + +The bknd CLI does not automatically have access to the Cloudflare bindings. We need to manually proxy them to the CLI by using the `withPlatformProxy` helper function: + +```typescript title="bknd.config.ts" +import { d1 } from "bknd/adapter/cloudflare"; +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; + +export default withPlatformProxy({ + app: ({ env }) => ({ + connection: d1({ binding: env.DB }), + }), +}); +``` + +Now you can use the CLI with your Cloudflare resources. + + + Make sure to not import from this file in your app, as this would include `wrangler` as a dependency. + + +Instead, it's recommended to split this configuration into separate files, e.g. `bknd.config.ts` and `config.ts`: + +```typescript title="config.ts" +import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare"; + +export default { + app: ({ env }) => ({ + connection: d1({ binding: env.DB }), + }), +} satisfies CloudflareBkndConfig; +``` + +`config.ts` now holds the configuration, and can safely be imported in your app. Since the CLI looks for a `bknd.config.ts` file by default, we change it to wrap the configuration from `config.ts` in the `withPlatformProxy` helper function. + +```typescript title="bknd.config.ts" +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config"; + +export default withPlatformProxy(config); +``` + +As an additional safe guard, you have to set a `PROXY` environment variable to `1` to enable the proxy. + +```bash +PROXY=1 npx bknd types +``` \ No newline at end of file diff --git a/docs/content/docs/(documentation)/meta.json b/docs/content/docs/(documentation)/meta.json index 896c981..b2d5db7 100644 --- a/docs/content/docs/(documentation)/meta.json +++ b/docs/content/docs/(documentation)/meta.json @@ -15,16 +15,18 @@ "./usage/sdk", "./usage/react", "./usage/elements", + "./usage/mcp/", "---Extending---", "./extending/config", "./extending/events", + "./extending/plugins", "---Integration---", "./integration/introduction", "./integration/(frameworks)/", "./integration/(runtimes)/", "---Modules---", "./modules/overview", - "./modules/server", + "./modules/server/", "./modules/data", "./modules/auth", "./modules/media", diff --git a/docs/content/docs/(documentation)/usage/cli.mdx b/docs/content/docs/(documentation)/usage/cli.mdx index 2874b94..2ccd875 100644 --- a/docs/content/docs/(documentation)/usage/cli.mdx +++ b/docs/content/docs/(documentation)/usage/cli.mdx @@ -16,22 +16,24 @@ Here is the output: $ npx bknd Usage: bknd [options] [command] -⚡ bknd cli v0.16.0 +⚡ bknd cli v0.17.0 Options: -V, --version output the version number -h, --help display help for command Commands: - user create/update users, or generate a token (auth) - types [options] generate types - schema [options] get schema - run [options] run an instance - debug debug bknd - create [options] create a new project - copy-assets [options] copy static assets - config [options] get default config - help [command] display help for command + config [options] get app config + copy-assets [options] copy static assets + create [options] create a new project + debug debug bknd + mcp [options] mcp server stdio transport + run [options] run an instance + schema [options] get schema + sync [options] sync database + types [options] generate types + user [options] create/update users, or generate a token (auth) + help [command] display help for command ``` ## Starting an instance (`run`) diff --git a/docs/content/docs/(documentation)/usage/database.mdx b/docs/content/docs/(documentation)/usage/database.mdx index 475404f..febffdd 100644 --- a/docs/content/docs/(documentation)/usage/database.mdx +++ b/docs/content/docs/(documentation)/usage/database.mdx @@ -349,6 +349,91 @@ Note that we didn't add relational fields directly to the entity, but instead de manually. +### System entities + +There are multiple system entities which are added depending on if the module is enabled: +- `users`: if authentication is enabled +- `media`: if media is enabled and an adapter is configured + +You can add additional fields to these entities. System-defined fields don't have to be repeated, those are automatically added to the entity, so don't worry about that. It's important though to match the system entities name, otherwise a new unrelated entity will be created. + +If you'd like to connect your entities to system entities, you need them in the schema to access their reference when making relations. From the example above, if you'd like to connect the `posts` entity to the `users` entity, you can do so like this: + +```typescript +import { em, entity, text, number, systemEntity } from "bknd"; + +const schema = em( + { + posts: entity("posts", { + title: text().required(), + slug: text().required(), + content: text(), + views: number(), + // don't add the foreign key field, it's automatically added + }), + comments: entity("comments", { + content: text(), + }), + // [!code highlight] + // add a `users` entity + users: systemEntity("users", { // [!code highlight] + // [!code highlight] + // optionally add additional fields + }) // [!code highlight] + }, + // now you have access to the system entity "users" + ({ relation, index }, { posts, comments, users }) => { + // ... other relations + relation(posts).manyToOne(users); // [!code highlight] + }, +); +``` + +### Add media to an entity + +If media is enabled, you can upload media directly or associate it with an entity. E.g. you may want to upload a cover image for a post, but also a gallery of images. Since a relation to the media entity is polymorphic, you have to: +1. add a virtual field to your entity (single `medium` or multiple `media`) +2. add the relation from the owning entity to the media entity +3. specify the mapped field name by using the `mappedBy` option + +```typescript +import { em, entity, text, number, systemEntity, medium, media } from "bknd"; + +const schema = em( + { + posts: entity("posts", { + title: text().required(), + slug: text().required(), + content: text(), + views: number(), + // [!code highlight] + // `medium` represents a single media item + cover: medium(), // [!code highlight] + // [!code highlight] + // `media` represents a list of media items + gallery: media(), // [!code highlight] + }), + comments: entity("comments", { + content: text(), + }), + // [!code highlight] + // add the `media` entity + media: systemEntity("media", { // [!code highlight] + // [!code highlight] + // optionally add additional fields + }) // [!code highlight] + }, + // now you have access to the system entity "media" + ({ relation, index }, { posts, comments, media }) => { + // add the `cover` relation + relation(posts).polyToOne(media, { mappedBy: "cover" }); // [!code highlight] + // add the `gallery` relation + relation(posts).polyToMany(media, { mappedBy: "gallery" }); // [!code highlight] + }, +); +``` + + ### Type completion To get type completion, there are two options: diff --git a/docs/content/docs/(documentation)/usage/elements.mdx b/docs/content/docs/(documentation)/usage/elements.mdx index d034a6e..239687f 100644 --- a/docs/content/docs/(documentation)/usage/elements.mdx +++ b/docs/content/docs/(documentation)/usage/elements.mdx @@ -44,16 +44,8 @@ export default function UserAvatar() { #### Props -- `initialItems?: xMediaFieldSchema[]`: Initial items to display, must be an array of media objects. -- `entity?: { name: string; id: number; field: string }`: If given, the initial media items fetched will be from this entity. -- `query?: RepoQueryIn`: Query to filter the media items. -- `overwrite?: boolean`: If true, the media item will be overwritten on entity media uploads if limit was reached. -- `maxItems?: number`: Maximum number of media items that can be uploaded. -- `autoUpload?: boolean`: If true, the media items will be uploaded automatically. -- `onRejected?: (files: FileWithPath[]) => void`: Callback when a file is rejected. -- `onDeleted?: (file: FileState) => void`: Callback when a file is deleted. -- `onUploaded?: (file: FileState) => void`: Callback when a file is uploaded. -- `placeholder?: { show?: boolean; text?: string }`: Placeholder text to show when no media items are present. + + #### Customize Rendering diff --git a/docs/content/docs/(documentation)/usage/mcp/meta.json b/docs/content/docs/(documentation)/usage/mcp/meta.json new file mode 100644 index 0000000..b85e570 --- /dev/null +++ b/docs/content/docs/(documentation)/usage/mcp/meta.json @@ -0,0 +1,4 @@ +{ + "title": "MCP", + "pages": ["overview", "tools-resources"] +} diff --git a/docs/content/docs/(documentation)/usage/mcp/overview.mdx b/docs/content/docs/(documentation)/usage/mcp/overview.mdx new file mode 100644 index 0000000..32f4354 --- /dev/null +++ b/docs/content/docs/(documentation)/usage/mcp/overview.mdx @@ -0,0 +1,208 @@ +--- +title: "Overview" +description: "Built-in full featured MCP server." +tags: ["documentation"] +--- +import { ImageZoom } from "fumadocs-ui/components/image-zoom"; +import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; + + + + The MCP server is currently experimental and may change in the future. During this period, it is disabled by default. To stabilize it, and make **bknd MCP native**, all configuration changes you can make today with the integrated Admin UI will be migrated to use the MCP server. + + +bknd includes a fully featured MCP server that can be used to interact with the bknd instance. It uses a lightweight MCP implementation that works in any environment bknd works in. Unlike other MCP servers, the exposed tools and resources are mainly dynamically generated from the schema, extracted from defined hono routes, and manually defined ones. This means exposed tools and resources are always up to date, and requires little overhead to maintain. + +- Fully featured, always up to date MCP server natively integrated with bknd +- Integrated MCP UI accessible from the Admin UI +- Built-in MCP client directly usable from your app instance +- CLI command to run an MCP server on stdio transport + + +## Integrated MCP UI + + +Once enabled, you can access the MCP UI at `/mcp`, or choose "MCP" from the top right user menu. + +## Enable MCP + +If you're using `initialConfig`, you can enable the MCP server by setting the `server.mcp.enabled` property to `true`. + +```typescript +import type { BkndConfig } from "bknd"; + +export default { + initialConfig: { + server: { + mcp: { + enabled: true, + } + } + } +} satisfies BkndConfig; +``` + +Using the Admin UI, you can either navigate to `/settings/server` or click top right on the user menu, select "Settings", then "Server". Enable the MCP server by checking the "Enabled" checkbox under "Mcp". + +## Using the MCP Client + +The implementation is closely following the [MCP spec 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18) powered by [jsonv-ts](https://github.com/jsonv-ts/jsonv-ts), therefore any spec compliant client will work. However, there is a built-in MCP client: + +```typescript +import { McpClient } from "bknd/utils"; + +const client = new McpClient({ + url: "http://localhost:1337/api/system/mcp", +}); +``` + +Alternatively, similar to the `getApi` function, you can use the `getMcpClient` function to get the client from your app instance that doesn't travel through the network. + +```typescript +import { createApp } from "bknd"; + +const app = createApp(); +const client = app.getMcpClient(); +``` + +Unlike the official spec requires, there is no initialization required, but supported. Without connecting, initialization or fetching the list of tools, you can directly call them. For example, you could fetch a list of `posts`: + +```typescript +const result = await client.callTool({ + name: "data_entity_read_many", + arguments: { + entity: "posts", + limit: 3, + select: ["id", "title"], + }, +}); +// { +// data: [ +// { id: 1, title: "Post 1" }, +// { id: 2, title: "Post 2" }, +// { id: 3, title: "Post 3" } +// ], +// } +``` + +Refer to the [jsonv-ts docs](https://github.com/dswbx/jsonv-ts#mcp-client) for more information. + +## STDIO Transport + +To start an MCP server on stdio transport, you can use the `mcp` CLI command. This is useful when you want to use it with IDEs or other tools that support stdio transport. + +```bash +npx bknd mcp +``` + +If you want have the Streamable HTTP endpoint disabled, you can still use the STDIO transport by passing the `--force` option. + +```bash +npx bknd mcp --force +``` + +## Usage in external tools + +You can also use the MCP server in external tools, such as VS Code, Cursor, or other IDEs that support MCP. This list is not exhaustive, and will be updated. + + + +Go to: `Settings` -> `Cursor Settings` -> `Tools & Integrations` -> `Add a custom MCP server` + +Pasting the following config into your Cursor `~/.cursor/mcp.json` file is the recommended approach. You can also install in a specific project by creating `.cursor/mcp.json` in your project folder. See [Cursor MCP docs](https://docs.cursor.com/context/model-context-protocol) for more info. + +```json tab="Local" +{ + "mcpServers": { + "bknd": { + "command": "npx", + "args": ["-y", "bknd@latest", "mcp"] + } + } +} +``` + +```json tab="Remote" +{ + "mcpServers": { + "bknd": { + "url": "http://localhost:1337/api/system/mcp" + } + } +} +``` + + + +Add this to your VS Code MCP config. See [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more info. + +```json tab="Local" +{ + "servers": { + "bknd": { + "type": "stdio", + "command": "npx", + "args": ["-y", "bknd@latest", "mcp"] + } + } +} +``` + +```json tab="Remote" +{ + "servers": { + "bknd": { + "type": "http", + "url": "http://localhost:1337/api/system/mcp" + } + } +} +``` + + + +Add this to your Claude Desktop `claude_desktop_config.json` file. See [Claude Desktop MCP docs](https://modelcontextprotocol.io/quickstart/user) for more info. + +```json title="claude_desktop_config.json" +{ + "mcpServers": { + "bknd": { + "command": "npx", + "args": ["-y", "bknd@latest", "mcp"] + } + } +} +``` + + + +If a tool you're using is not listed here, please let us know by [opening an issue](https://github.com/bknd-dev/bknd/issues/new) or [contacting us on Discord](https://discord.com/invite/Qjz9nNHYTB). + +## Authentication + +Both the Streamable HTTP and STDIO transport support authentication. The same authentication mechanism as the API is used, so permissions work the exact same way. + +When using the Streamable HTTP transport, you can pass the `Authorization` header to the client. + +```typescript +const client = new McpClient({ + url: "http://localhost:1337/api/system/mcp", + headers: { + Authorization: `Bearer ${token}`, + }, +}); +``` + +When using the STDIO transport, you can pass an `--token` option to the CLI command. + +```bash +npx bknd mcp --token +``` + +Alternatively, you can also use the `BEARER_TOKEN` environment variable. + +```bash +BEARER_TOKEN= npx bknd mcp +``` diff --git a/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx b/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx new file mode 100644 index 0000000..6cb798e --- /dev/null +++ b/docs/content/docs/(documentation)/usage/mcp/tools-resources.mdx @@ -0,0 +1,355 @@ +--- +title: "MCP" +description: "Tools & Resources of the built-in full featured MCP server." +tags: ["documentation"] +--- +import { JsonSchemaTypeTable } from '@/components/McpTool'; + +## Tools + + +### `auth_me` + +Get the current user + + + +### `auth_strategies` + +Get the available authentication strategies + + + +### `auth_user_create` + +Create a new user + + + +### `auth_user_password_change` + +Change a user's password + + + +### `auth_user_password_test` + +Test a user's password + + + +### `auth_user_token` + +Get a user token + + + +### `data_entity_delete_many` + +Delete many + + + +### `data_entity_delete_one` + +Delete one + + + +### `data_entity_fn_count` + +Count entities + + + +### `data_entity_fn_exists` + +Check if entity exists + + + +### `data_entity_info` + +Retrieve entity info + + + +### `data_entity_insert` + +Insert one or many + + + +### `data_entity_read_many` + +Query entities + + + +### `data_entity_read_one` + +Read one + + + +### `data_entity_update_many` + +Update many + + + +### `data_entity_update_one` + +Update one + + + +### `data_sync` + +Sync database schema + + + +### `system_build` + +Build the app + + + +### `system_config` + +Get the config for a module + + + +### `system_info` + +Get the server info + + + +### `system_ping` + +Ping the server + + + +### `config_auth_get` + + + + + +### `config_auth_roles_add` + + + + + +### `config_auth_roles_get` + + + + + +### `config_auth_roles_remove` + + + + + +### `config_auth_roles_update` + + + + + +### `config_auth_strategies_add` + + + + + +### `config_auth_strategies_get` + + + + + +### `config_auth_strategies_remove` + + + + + +### `config_auth_strategies_update` + + + + + +### `config_auth_update` + + + + + +### `config_data_entities_add` + + + + + +### `config_data_entities_get` + + + + + +### `config_data_entities_remove` + + + + + +### `config_data_entities_update` + + + + + +### `config_data_get` + + + + + +### `config_data_indices_add` + + + + + +### `config_data_indices_get` + + + + + +### `config_data_indices_remove` + + + + + +### `config_data_relations_add` + + + + + +### `config_data_relations_get` + + + + + +### `config_data_relations_remove` + + + + + +### `config_data_relations_update` + + + + + +### `config_data_update` + + + + + +### `config_media_adapter_get` + + + + + +### `config_media_adapter_update` + + + + + +### `config_media_get` + + + + + +### `config_media_update` + + + + + +### `config_server_get` + +Get Server configuration + + + +### `config_server_update` + +Update Server configuration + + + + +## Resources + + + +### `data_entities` + +Retrieve all entities + + + +### `data_relations` + +Retrieve all relations + + + +### `data_indices` + +Retrieve all indices + + + +### `system_config` + + + + + +### `system_config_module` + + + + + +### `system_schema` + + + + + +### `system_schema_module` + + + diff --git a/docs/content/docs/(documentation)/usage/sdk.mdx b/docs/content/docs/(documentation)/usage/sdk.mdx index a0c6f63..70c194a 100644 --- a/docs/content/docs/(documentation)/usage/sdk.mdx +++ b/docs/content/docs/(documentation)/usage/sdk.mdx @@ -199,6 +199,37 @@ To delete many records of an entity, use the `deleteMany` method: const { data } = await api.data.deleteMany("posts", { views: { $lte: 1 } }); ``` +### `data.readManyByReference([entity], [id], [reference], [query])` + +To retrieve records from a related entity by following a reference, use the `readManyByReference` method: + +```ts +const { data } = await api.data.readManyByReference("posts", 1, "comments", { + limit: 5, + sort: "-created_at", +}); +``` + +### `data.count([entity], [where])` + +To count records in an entity that match certain criteria, use the `count` method: + +```ts +const { data } = await api.data.count("posts", { + views: { $gt: 100 } +}); +``` + +### `data.exists([entity], [where])` + +To check if any records exist in an entity that match certain criteria, use the `exists` method: + +```ts +const { data } = await api.data.exists("posts", { + title: "Hello, World!" +}); +``` + ## Auth (`api.auth`) Access the `Auth` specific API methods at `api.auth`. If there is successful authentication, the @@ -241,3 +272,89 @@ To retrieve the current user, use the `me` method: ```ts const { data } = await api.auth.me(); ``` + +## Media (`api.media`) + +Access the `Media` specific API methods at `api.media`. + +### `media.listFiles()` + +To retrieve a list of all uploaded files, use the `listFiles` method: + +```ts +const { data } = await api.media.listFiles(); +// ^? FileListObject[] +``` + +### `media.getFile([filename])` + +To retrieve a file as a readable stream, use the `getFile` method: + +```ts +const { data } = await api.media.getFile("image.jpg"); +// ^? ReadableStream +``` + +### `media.getFileStream([filename])` + +To get a file stream directly, use the `getFileStream` method: + +```ts +const stream = await api.media.getFileStream("image.jpg"); +// ^? ReadableStream +``` + +### `media.download([filename])` + +To download a file as a File object, use the `download` method: + +```ts +const file = await api.media.download("image.jpg"); +// ^? File +``` + +### `media.upload([item], [options])` + +To upload a file, use the `upload` method. The item can be any of: +- `File` object +- `Request` object +- `Response` object +- `string` (URL) +- `ReadableStream` +- `Buffer` +- `Blob` + +```ts +// Upload a File object +const { data } = await api.media.upload(item); + +// Upload from a URL +const { data } = await api.media.upload("https://example.com/image.jpg"); + +// Upload with custom options +const { data } = await api.media.upload(item, { + filename: "custom-name.jpg", +}); +``` + +### `media.uploadToEntity([entity], [id], [field], [item], [options])` + +To upload a file directly to an entity field, use the `uploadToEntity` method: + +```ts +const { data } = await api.media.uploadToEntity( + "posts", + 1, + "image", + item +); +``` + +### `media.deleteFile([filename])` + +To delete a file, use the `deleteFile` method: + +```ts +const { data } = await api.media.deleteFile("image.jpg"); +``` + diff --git a/docs/mcp.json b/docs/mcp.json new file mode 100644 index 0000000..df16c4b --- /dev/null +++ b/docs/mcp.json @@ -0,0 +1,4049 @@ +{ + "tools": [ + { + "name": "auth_me", + "description": "Get the current user", + "inputSchema": { + "type": "object" + } + }, + { + "name": "auth_strategies", + "description": "Get the available authentication strategies", + "inputSchema": { + "type": "object", + "properties": { + "include_disabled": { + "type": "boolean", + "$target": "query" + } + } + } + }, + { + "name": "auth_user_create", + "description": "Create a new user", + "inputSchema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8 + }, + "role": { + "type": "string", + "enum": [] + } + }, + "required": [ + "email", + "password" + ] + } + }, + { + "name": "auth_user_password_change", + "description": "Change a user's password", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "number", + "title": "Integer" + }, + { + "type": "string", + "title": "UUID" + } + ] + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8 + } + }, + "required": [ + "password" + ] + } + }, + { + "name": "auth_user_password_test", + "description": "Test a user's password", + "inputSchema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8 + } + }, + "required": [ + "email", + "password" + ] + } + }, + { + "name": "auth_user_token", + "description": "Get a user token", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "number", + "title": "Integer" + }, + { + "type": "string", + "title": "UUID" + } + ] + }, + "email": { + "type": "string", + "format": "email" + } + } + } + }, + { + "name": "data_entity_delete_many", + "description": "Delete many", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "json" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "json": { + "type": "object", + "$synthetic": true, + "$target": "json", + "properties": {} + } + } + } + }, + { + "name": "data_entity_delete_one", + "description": "Delete one", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "id" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "id": { + "anyOf": [ + { + "type": "number", + "title": "Integer" + }, + { + "type": "string", + "title": "UUID" + } + ], + "$target": "param" + } + } + } + }, + { + "name": "data_entity_fn_count", + "description": "Count entities", + "inputSchema": { + "type": "object", + "required": [ + "entity" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "json": { + "examples": [ + { + "attribute": { + "$eq": 1 + } + } + ], + "default": {}, + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {} + } + ], + "$synthetic": true, + "$target": "json" + } + } + } + }, + { + "name": "data_entity_fn_exists", + "description": "Check if entity exists", + "inputSchema": { + "type": "object", + "required": [ + "entity" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "json": { + "examples": [ + { + "attribute": { + "$eq": 1 + } + } + ], + "default": {}, + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {} + } + ], + "$synthetic": true, + "$target": "json" + } + } + } + }, + { + "name": "data_entity_info", + "description": "Retrieve entity info", + "inputSchema": { + "type": "object", + "required": [ + "entity" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + } + } + } + }, + { + "name": "data_entity_insert", + "description": "Insert one or many", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "json" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "json": { + "anyOf": [ + { + "type": "object", + "properties": {} + }, + { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + } + ], + "$synthetic": true, + "$target": "json" + } + } + } + }, + { + "name": "data_entity_read_many", + "description": "Query entities", + "inputSchema": { + "type": "object", + "required": [ + "entity" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "limit": { + "type": "number", + "default": 10, + "$target": "json" + }, + "offset": { + "type": "number", + "default": 0, + "$target": "json" + }, + "sort": { + "type": "string", + "default": "id", + "$target": "json" + }, + "where": { + "examples": [ + { + "attribute": { + "$eq": 1 + } + } + ], + "default": {}, + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {} + } + ], + "$synthetic": true, + "$target": "json" + }, + "select": { + "type": "array", + "$target": "json", + "items": { + "type": "string" + } + }, + "join": { + "type": "array", + "$target": "json", + "items": { + "type": "string" + } + }, + "with": { + "type": "object", + "$target": "json", + "properties": {} + } + } + } + }, + { + "name": "data_entity_read_one", + "description": "Read one", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "id" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "id": { + "anyOf": [ + { + "type": "number", + "title": "Integer" + }, + { + "type": "string", + "title": "UUID" + } + ], + "$target": "param" + }, + "offset": { + "type": "number", + "default": 0, + "$target": "query" + }, + "sort": { + "type": "string", + "default": "id", + "$target": "query" + }, + "select": { + "type": "array", + "$target": "query", + "items": { + "type": "string" + } + } + } + } + }, + { + "name": "data_entity_update_many", + "description": "Update many", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "update", + "where" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "update": { + "type": "object", + "$target": "json", + "properties": {} + }, + "where": { + "type": "object", + "$target": "json", + "properties": {} + } + } + } + }, + { + "name": "data_entity_update_one", + "description": "Update one", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "id", + "json" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "id": { + "anyOf": [ + { + "type": "number", + "title": "Integer" + }, + { + "type": "string", + "title": "UUID" + } + ], + "$target": "param" + }, + "json": { + "type": "object", + "$synthetic": true, + "$target": "json", + "properties": {} + } + } + } + }, + { + "name": "data_sync", + "description": "Sync database schema", + "inputSchema": { + "type": "object", + "properties": { + "force": { + "type": "boolean", + "$target": "query" + }, + "drop": { + "type": "boolean", + "$target": "query" + } + } + }, + "annotations": { + "destructiveHint": true + } + }, + { + "name": "system_build", + "description": "Build the app", + "inputSchema": { + "type": "object", + "properties": { + "sync": { + "type": "boolean", + "$target": "query" + }, + "fetch": { + "type": "boolean", + "$target": "query" + } + } + } + }, + { + "name": "system_config", + "description": "Get the config for a module", + "inputSchema": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "server", + "data", + "auth", + "media", + "flows" + ], + "$target": "param" + }, + "secrets": { + "type": "boolean", + "$target": "query" + } + } + }, + "annotations": { + "readOnlyHint": true + } + }, + { + "name": "system_info", + "description": "Get the server info", + "inputSchema": { + "type": "object" + } + }, + { + "name": "system_ping", + "description": "Ping the server", + "inputSchema": { + "type": "object" + } + }, + { + "name": "config_auth_get", + "title": "Get Authentication", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the property to get, e.g. `key.subkey`", + "pattern": "^[a-zA-Z0-9_.]{0,}$" + }, + "depth": { + "type": "number", + "description": "Limit the depth of the response" + }, + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "config_auth_roles_add", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "is_default": { + "type": "boolean" + }, + "implicit_allow": { + "type": "boolean" + } + } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_roles_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "config_auth_roles_remove", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_roles_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to update" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "is_default": { + "type": "boolean" + }, + "implicit_allow": { + "type": "boolean" + } + } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_strategies_add", + "title": "Add Strategies", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string" + }, + "enabled": { + "type": "boolean", + "default": true + }, + "config": { + "type": "object", + "properties": {} + } + }, + "required": [ + "type", + "config" + ] + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_strategies_get", + "title": "Get Strategies", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "config_auth_strategies_remove", + "title": "Get Strategies", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_strategies_update", + "title": "Update Strategies", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to update" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string" + }, + "enabled": { + "type": "boolean", + "default": true + }, + "config": { + "type": "object", + "properties": {} + } + } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_update", + "title": "Update Authentication", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "full": { + "type": "boolean", + "default": false + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "basepath": { + "type": "string", + "default": "/api/auth" + }, + "entity_name": { + "type": "string", + "default": "users" + }, + "allow_register": { + "type": "boolean", + "default": true + }, + "jwt": { + "type": "object", + "default": {}, + "additionalProperties": false, + "properties": { + "secret": { + "type": "string", + "default": "" + }, + "alg": { + "type": "string", + "enum": [ + "HS256", + "HS384", + "HS512" + ], + "default": "HS256" + }, + "expires": { + "type": "number" + }, + "issuer": { + "type": "string" + }, + "fields": { + "type": "array", + "default": [ + "id", + "email", + "role" + ], + "items": { + "type": "string" + } + } + } + }, + "cookie": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "default": "/" + }, + "sameSite": { + "type": "string", + "enum": [ + "strict", + "lax", + "none" + ], + "default": "lax" + }, + "secure": { + "type": "boolean", + "default": true + }, + "httpOnly": { + "type": "boolean", + "default": true + }, + "expires": { + "type": "number", + "default": 604800 + }, + "partitioned": { + "type": "boolean", + "default": false + }, + "renew": { + "type": "boolean", + "default": true + }, + "pathSuccess": { + "type": "string", + "default": "/" + }, + "pathLoggedOut": { + "type": "string", + "default": "/" + } + } + }, + "guard": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + } + } + } + } + } + }, + "required": [ + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_entities_add", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "regular", + "system", + "generated" + ], + "default": "regular" + }, + "config": { + "type": "object", + "default": {}, + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "name_singular": { + "type": "string" + }, + "description": { + "type": "string" + }, + "sort_field": { + "type": "string", + "default": "id" + }, + "sort_dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "asc" + }, + "primary_format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ] + } + } + }, + "fields": { + "type": "object", + "default": {}, + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "title": "primary", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "primary" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ], + "default": "integer" + }, + "required": { + "type": "boolean", + "default": false + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "text", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "text" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "string" + }, + "minLength": { + "type": "number" + }, + "maxLength": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "html_config": { + "type": "object", + "properties": { + "element": { + "type": "string" + }, + "props": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "title": "String" + }, + { + "type": "number", + "title": "Number" + } + ] + } + } + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "number", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "number" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "multipleOf": { + "type": "number" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "boolean", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "boolean" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "date", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "date" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "datetime", + "week" + ], + "default": "date" + }, + "timezone": { + "type": "string" + }, + "min_date": { + "type": "string" + }, + "max_date": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "enum", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "enum" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "string" + }, + "options": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "strings" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "values" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "objects" + }, + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ] + } + } + }, + "required": [ + "type", + "values" + ] + } + ] + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "json", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "json" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": {}, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "jsonschema", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "jsonschema" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "schema": { + "type": "object" + }, + "ui_schema": { + "type": "object" + }, + "default_from_schema": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "relation", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "relation" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "reference": { + "type": "string" + }, + "target": { + "type": "string" + }, + "target_field": { + "type": "string", + "default": "id" + }, + "target_field_type": { + "type": "string", + "enum": [ + "text", + "integer" + ], + "default": "integer" + }, + "on_delete": { + "type": "string", + "enum": [ + "cascade", + "set null", + "set default", + "restrict", + "no action" + ], + "default": "set null" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + }, + "required": [ + "reference", + "target" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "media", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "media" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "entity": { + "type": "string" + }, + "min_items": { + "type": "number" + }, + "max_items": { + "type": "number" + }, + "mime_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + }, + "required": [ + "type" + ] + } + ] + } + } + } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_entities_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "config_data_entities_remove", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_entities_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to update" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "regular", + "system", + "generated" + ], + "default": "regular" + }, + "config": { + "type": "object", + "default": {}, + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "name_singular": { + "type": "string" + }, + "description": { + "type": "string" + }, + "sort_field": { + "type": "string", + "default": "id" + }, + "sort_dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "asc" + }, + "primary_format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ] + } + } + }, + "fields": { + "type": "object", + "default": {}, + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "title": "primary", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "primary" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ], + "default": "integer" + }, + "required": { + "type": "boolean", + "default": false + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + }, + { + "type": "object", + "title": "text", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "text" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "string" + }, + "minLength": { + "type": "number" + }, + "maxLength": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "html_config": { + "type": "object", + "properties": { + "element": { + "type": "string" + }, + "props": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "title": "String" + }, + { + "type": "number", + "title": "Number" + } + ] + } + } + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "number", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "number" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "multipleOf": { + "type": "number" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "boolean", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "boolean" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "date", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "date" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "datetime", + "week" + ], + "default": "date" + }, + "timezone": { + "type": "string" + }, + "min_date": { + "type": "string" + }, + "max_date": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + }, + { + "type": "object", + "title": "enum", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "enum" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "string" + }, + "options": { + "anyOf": [ + { + "type": "object", + "required": [ + "type", + "values" + ], + "properties": { + "type": { + "const": "strings" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "type": "object", + "required": [ + "type", + "values" + ], + "properties": { + "type": { + "const": "objects" + }, + "values": { + "type": "array", + "items": { + "type": "object", + "required": [ + "label", + "value" + ], + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + } + ] + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "json", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "json" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": {}, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "jsonschema", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "jsonschema" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "schema": { + "type": "object" + }, + "ui_schema": { + "type": "object" + }, + "default_from_schema": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + }, + { + "type": "object", + "title": "relation", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "relation" + }, + "config": { + "type": "object", + "additionalProperties": false, + "required": [ + "reference", + "target" + ], + "properties": { + "reference": { + "type": "string" + }, + "target": { + "type": "string" + }, + "target_field": { + "type": "string", + "default": "id" + }, + "target_field_type": { + "type": "string", + "enum": [ + "text", + "integer" + ], + "default": "integer" + }, + "on_delete": { + "type": "string", + "enum": [ + "cascade", + "set null", + "set default", + "restrict", + "no action" + ], + "default": "set null" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + }, + { + "type": "object", + "title": "media", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "media" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "entity": { + "type": "string" + }, + "min_items": { + "type": "number" + }, + "max_items": { + "type": "number" + }, + "mime_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + } + ] + } + } + } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the property to get, e.g. `key.subkey`", + "pattern": "^[a-zA-Z0-9_.]{0,}$" + }, + "depth": { + "type": "number", + "description": "Limit the depth of the response" + }, + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "config_data_indices_add", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "entity": { + "type": "string" + }, + "fields": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "unique": { + "type": "boolean", + "default": false + } + }, + "required": [ + "entity", + "fields" + ] + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_indices_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "config_data_indices_remove", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_relations_add", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "1:1", + "n:1", + "m:n", + "poly" + ] + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "config": { + "type": "object", + "properties": {} + } + }, + "required": [ + "type", + "source", + "target" + ] + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_relations_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "config_data_relations_remove", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_relations_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to update" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "1:1", + "n:1", + "m:n", + "poly" + ] + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "config": { + "type": "object", + "properties": {} + } + } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "full": { + "type": "boolean", + "default": false + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "basepath": { + "type": "string", + "default": "/api/data" + }, + "default_primary_format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ], + "default": "integer" + } + } + } + }, + "required": [ + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_media_adapter_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_media_adapter_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { + "anyOf": [ + { + "type": "object", + "title": "AWS S3", + "description": "AWS S3 or compatible storage", + "additionalProperties": false, + "properties": { + "type": { + "const": "s3" + }, + "config": { + "type": "object", + "title": "AWS S3", + "description": "AWS S3 or compatible storage", + "properties": { + "access_key": { + "type": "string" + }, + "secret_access_key": { + "type": "string" + }, + "url": { + "type": "string", + "description": "URL to S3 compatible endpoint without trailing slash", + "examples": [ + "https://{account_id}.r2.cloudflarestorage.com/{bucket}", + "https://{bucket}.s3.{region}.amazonaws.com" + ], + "pattern": "^https?://(?:.*)?[^/.]+$" + } + }, + "required": [ + "access_key", + "secret_access_key", + "url" + ] + } + }, + "required": [ + "type", + "config" + ] + }, + { + "type": "object", + "title": "Cloudinary", + "description": "Cloudinary media storage", + "additionalProperties": false, + "properties": { + "type": { + "const": "cloudinary" + }, + "config": { + "type": "object", + "title": "Cloudinary", + "description": "Cloudinary media storage", + "properties": { + "cloud_name": { + "type": "string" + }, + "api_key": { + "type": "string" + }, + "api_secret": { + "type": "string" + }, + "upload_preset": { + "type": "string" + } + }, + "required": [ + "cloud_name", + "api_key", + "api_secret" + ] + } + }, + "required": [ + "type", + "config" + ] + }, + { + "type": "object", + "title": "Local", + "description": "Local file system storage", + "additionalProperties": false, + "properties": { + "type": { + "const": "local" + }, + "config": { + "type": "object", + "title": "Local", + "description": "Local file system storage", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "default": "./" + } + }, + "required": [ + "path" + ] + } + }, + "required": [ + "type", + "config" + ] + } + ] + }, + "return_config": { + "type": "boolean", + "default": false + }, + "secrets": { + "type": "boolean", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_media_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the property to get, e.g. `key.subkey`", + "pattern": "^[a-zA-Z0-9_.]{0,}$" + }, + "depth": { + "type": "number", + "description": "Limit the depth of the response" + }, + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "config_media_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "full": { + "type": "boolean", + "default": false + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "basepath": { + "type": "string", + "default": "/api/media" + }, + "entity_name": { + "type": "string", + "default": "media" + }, + "storage": { + "type": "object", + "default": {}, + "additionalProperties": false, + "properties": { + "body_max_size": { + "type": "number", + "description": "Max size of the body in bytes. Leave blank for unlimited." + } + } + } + } + } + }, + "required": [ + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_server_get", + "description": "Get Server configuration", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the property to get, e.g. `key.subkey`", + "pattern": "^[a-zA-Z0-9_.]{0,}$" + }, + "depth": { + "type": "number", + "description": "Limit the depth of the response" + }, + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "config_server_update", + "description": "Update Server configuration", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "full": { + "type": "boolean", + "default": false + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "cors": { + "type": "object", + "additionalProperties": false, + "properties": { + "origin": { + "type": "string", + "default": "*" + }, + "allow_methods": { + "type": "array", + "default": [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE" + ], + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE" + ] + } + }, + "allow_headers": { + "type": "array", + "default": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ], + "items": { + "type": "string" + } + }, + "allow_credentials": { + "type": "boolean", + "default": true + } + } + }, + "mcp": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + } + } + } + } + } + }, + "required": [ + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + } + ], + "resources": [ + { + "uri": "bknd://data/entities", + "name": "data_entities", + "title": "Entities", + "description": "Retrieve all entities" + }, + { + "uri": "bknd://data/relations", + "name": "data_relations", + "title": "Relations", + "description": "Retrieve all relations" + }, + { + "uri": "bknd://data/indices", + "name": "data_indices", + "title": "Indices", + "description": "Retrieve all indices" + }, + { + "uri": "bknd://system/config", + "name": "system_config" + }, + { + "uriTemplate": "bknd://system/config/{module}", + "name": "system_config_module" + }, + { + "uri": "bknd://system/schema", + "name": "system_schema" + }, + { + "uriTemplate": "bknd://system/schema/{module}", + "name": "system_schema_module" + } + ] +} \ No newline at end of file diff --git a/docs/mdx-components.tsx b/docs/mdx-components.tsx index 339807f..09e5219 100644 --- a/docs/mdx-components.tsx +++ b/docs/mdx-components.tsx @@ -5,11 +5,12 @@ import type { MDXComponents } from "mdx/types"; import * as FilesComponents from "fumadocs-ui/components/files"; import * as TabsComponents from "fumadocs-ui/components/tabs"; import { Accordion, Accordions } from "fumadocs-ui/components/accordion"; +import { ImageZoom } from "fumadocs-ui/components/image-zoom"; import { - CalloutInfo, - CalloutPositive, - CalloutCaution, - CalloutDanger, + CalloutInfo, + CalloutPositive, + CalloutCaution, + CalloutDanger, } from "./app/_components/Callout"; import { StackBlitz } from "./app/_components/StackBlitz"; import { Icon } from "@iconify/react"; @@ -18,29 +19,30 @@ import * as Twoslash from "fumadocs-twoslash/ui"; import { createGenerator } from "fumadocs-typescript"; import { AutoTypeTable } from "fumadocs-typescript/ui"; +import { Wrapper } from "@/components/misc/Wrapper"; const generator = createGenerator({ - tsconfigPath: "../tsconfig.json", + tsconfigPath: "../tsconfig.json", }); export function getMDXComponents(components?: MDXComponents): MDXComponents { - return { - ...defaultMdxComponents, - ...TabsComponents, - ...FilesComponents, - ...Twoslash, - Accordion, - Accordions, - CalloutInfo, - CalloutPositive, - CalloutCaution, - CalloutDanger, - StackBlitz, - Icon, - APIPage: (props) => , - AutoTypeTable: (props) => ( - - ), - ...components, - }; + return { + ...defaultMdxComponents, + ...TabsComponents, + ...FilesComponents, + ...Twoslash, + Accordion, + Accordions, + CalloutInfo, + CalloutPositive, + CalloutCaution, + CalloutDanger, + StackBlitz, + Icon, + Wrapper, + img: (props) => , + APIPage: (props) => , + AutoTypeTable: (props) => , + ...components, + }; } diff --git a/docs/package-lock.json b/docs/package-lock.json index 9db3d56..8fb69ff 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -6,7 +6,6 @@ "packages": { "": { "name": "bknd-docs", - "version": "0.0.0", "hasInstallScript": true, "dependencies": { "@iconify/react": "^6.0.0", @@ -29,6 +28,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", + "@types/bun": "^1.2.19", "@types/mdx": "^2.0.13", "@types/node": "24.0.10", "@types/react": "^19.1.8", @@ -36,6 +36,7 @@ "eslint": "^8", "eslint-config-next": "15.3.5", "fumadocs-docgen": "^2.1.0", + "jsonv-ts": "^0.7.0", "postcss": "^8.5.6", "rimraf": "^6.0.1", "tailwindcss": "^4.1.11", @@ -3281,6 +3282,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bun": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.2.19.tgz", + "integrity": "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.2.19" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -4382,6 +4393,19 @@ "node": ">=8" } }, + "node_modules/bun-types": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.19.tgz", + "integrity": "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "peerDependencies": { + "@types/react": "^19" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -6816,6 +6840,17 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hono": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.9.0.tgz", + "integrity": "sha512-JAUc4Sqi3lhby2imRL/67LMcJFKiCu7ZKghM7iwvltVZzxEC5bVJCsAa4NTnSfmWGb+N2eOVtFE586R+K3fejA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -7525,6 +7560,19 @@ "node": ">=0.10.0" } }, + "node_modules/jsonv-ts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/jsonv-ts/-/jsonv-ts-0.7.0.tgz", + "integrity": "sha512-zN5/KMs1WOs+0IbYiZF7mVku4dum8LKP9xv8VqgVm+PBz5VZuU1V8iLQhI991ogUbhGHHlOCwqxnxQUuvCPbQA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "hono": "*" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", diff --git a/docs/package.json b/docs/package.json index 486c83c..346a3be 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,9 +4,10 @@ "scripts": { "dev": "next dev", "dev:turbo": "next dev --turbo", - "build": "bun generate:openapi && next build", + "build": "bun generate:openapi && bun generate:mcp && next build", "start": "next start", "generate:openapi": "bun scripts/generate-openapi.mjs", + "generate:mcp": "bun scripts/generate-mcp.ts", "postinstall": "fumadocs-mdx", "preview": "npm run build && wrangler dev", "cf:preview": "wrangler dev", @@ -35,6 +36,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.11", + "@types/bun": "^1.2.19", "@types/mdx": "^2.0.13", "@types/node": "24.0.10", "@types/react": "^19.1.8", @@ -42,6 +44,7 @@ "eslint": "^8", "eslint-config-next": "15.3.5", "fumadocs-docgen": "^2.1.0", + "jsonv-ts": "^0.7.0", "postcss": "^8.5.6", "rimraf": "^6.0.1", "tailwindcss": "^4.1.11", diff --git a/docs/public/content/mcp/bknd_mcp_ui.png b/docs/public/content/mcp/bknd_mcp_ui.png new file mode 100644 index 0000000..e242ad7 Binary files /dev/null and b/docs/public/content/mcp/bknd_mcp_ui.png differ diff --git a/docs/public/content/mcp/v0.17_mcp_o.mp4 b/docs/public/content/mcp/v0.17_mcp_o.mp4 new file mode 100644 index 0000000..08f3c21 Binary files /dev/null and b/docs/public/content/mcp/v0.17_mcp_o.mp4 differ diff --git a/docs/public/content/mcp/v0.17_mcp_o_frame_12.5s.jpg b/docs/public/content/mcp/v0.17_mcp_o_frame_12.5s.jpg new file mode 100644 index 0000000..e4e1fd6 Binary files /dev/null and b/docs/public/content/mcp/v0.17_mcp_o_frame_12.5s.jpg differ diff --git a/docs/scripts/generate-mcp.ts b/docs/scripts/generate-mcp.ts new file mode 100644 index 0000000..1cb440c --- /dev/null +++ b/docs/scripts/generate-mcp.ts @@ -0,0 +1,69 @@ +/// + +import type { Tool, Resource } from "jsonv-ts/mcp"; +import { rimraf } from "rimraf"; +import { writeFile, readFile } from "node:fs/promises"; + +const config = { + mcpConfig: "./mcp.json", + outFile: "./content/docs/(documentation)/usage/mcp/tools-resources.mdx", +}; + +async function generate() { + console.info("Generating MCP documentation..."); + + await cleanup(); + const mcpConfig = JSON.parse(await readFile(config.mcpConfig, "utf-8")); + const document = await generateDocument(mcpConfig); + await writeFile(config.outFile, document, "utf-8"); + console.info("MCP documentation generated."); +} + +async function generateDocument({ + tools, + resources, +}: { + tools: ReturnType[]; + resources: ReturnType[]; +}) { + return `--- +title: "MCP" +description: "Tools & Resources of the built-in full featured MCP server." +tags: ["documentation"] +--- +import { JsonSchemaTypeTable } from '@/components/McpTool'; + +## Tools + +${tools + .map( + (t) => ` +### \`${t.name}\` + +${t.description ?? ""} + +`, + ) + .join("\n")} + + +## Resources + +${resources + .map( + (r) => ` + +### \`${r.name}\` + +${r.description ?? ""} +`, + ) + .join("\n")} +`; +} + +async function cleanup() { + await rimraf(config.outFile); +} + +void generate(); diff --git a/examples/cloudflare-worker/.gitignore b/examples/cloudflare-worker/.gitignore index 3b0fe33..6fd7128 100644 --- a/examples/cloudflare-worker/.gitignore +++ b/examples/cloudflare-worker/.gitignore @@ -170,3 +170,5 @@ dist .dev.vars .wrangler/ +bknd-types.d.ts +worker-configuration.d.ts \ No newline at end of file diff --git a/examples/cloudflare-worker/bknd.config.ts b/examples/cloudflare-worker/bknd.config.ts new file mode 100644 index 0000000..74948a8 --- /dev/null +++ b/examples/cloudflare-worker/bknd.config.ts @@ -0,0 +1,11 @@ +/** + * Optionally wrapping the configuration with the `withPlatformProxy` function + * enables programmatic access to the bindings, e.g. for generating types. + * + * We're using separate files, so that "wrangler" doesn't get bundled with your worker. + */ + +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config); diff --git a/examples/cloudflare-worker/config.ts b/examples/cloudflare-worker/config.ts new file mode 100644 index 0000000..0309982 --- /dev/null +++ b/examples/cloudflare-worker/config.ts @@ -0,0 +1,21 @@ +import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare"; +import { syncTypes } from "bknd/plugins"; +import { writeFile } from "node:fs/promises"; + +const isDev = import.meta.env && !import.meta.env.PROD; + +export default { + d1: { + session: true, + }, + options: { + plugins: [ + syncTypes({ + enabled: isDev, + write: async (et) => { + await writeFile("bknd-types.d.ts", et.toString()); + }, + }), + ], + }, +} satisfies CloudflareBkndConfig; diff --git a/examples/cloudflare-worker/package.json b/examples/cloudflare-worker/package.json index d283159..02faf9d 100644 --- a/examples/cloudflare-worker/package.json +++ b/examples/cloudflare-worker/package.json @@ -2,16 +2,19 @@ "name": "cloudflare-worker", "version": "0.0.0", "private": true, + "type": "module", "scripts": { "deploy": "wrangler deploy", "dev": "wrangler dev", - "typegen": "wrangler types" + "bknd-typegen": "PROXY=1 npx bknd types", + "typegen": "wrangler types && npm run bknd-typegen", + "predev": "npm run typegen" }, "dependencies": { "bknd": "file:../../app" }, "devDependencies": { - "typescript": "^5.8.3", - "wrangler": "^4.19.1" + "typescript": "^5.9.2", + "wrangler": "^4.28.1" } } diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index cae6a1b..36558c7 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -1,8 +1,4 @@ import { serve } from "bknd/adapter/cloudflare"; +import config from "../config"; -export default serve({ - mode: "warm", - d1: { - session: true, - }, -}); +export default serve(config); diff --git a/examples/cloudflare-worker/tsconfig.json b/examples/cloudflare-worker/tsconfig.json index 9879d52..7c9e18e 100644 --- a/examples/cloudflare-worker/tsconfig.json +++ b/examples/cloudflare-worker/tsconfig.json @@ -1,11 +1,9 @@ { "compilerOptions": { - "target": "es2021", - "lib": ["es2021"], - "jsx": "react-jsx", - "module": "es2022", - "moduleResolution": "Bundler", - "types": ["./worker-configuration.d.ts"], + "lib": ["ES2022"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", "resolveJsonModule": true, "allowJs": true, "checkJs": false, @@ -13,10 +11,16 @@ "isolatedModules": true, "noImplicitAny": false, "allowSyntheticDefaultImports": true, + "allowImportingTsExtensions": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true }, "exclude": ["test"], - "include": ["worker-configuration.d.ts", "src/**/*.ts"] + "include": [ + "worker-configuration.d.ts", + "bknd-types.d.ts", + "bknd.config.ts", + "src/**/*.ts" + ] } diff --git a/examples/cloudflare-worker/worker-configuration.d.ts b/examples/cloudflare-worker/worker-configuration.d.ts deleted file mode 100644 index 9f7bd6c..0000000 --- a/examples/cloudflare-worker/worker-configuration.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// placeholder, run generation again -declare namespace Cloudflare { - interface Env { - BUCKET: R2Bucket; - DB: D1Database; - } -} -interface Env extends Cloudflare.Env {}