Merge pull request #225 from bknd-io/feat/mcp

initialized mcp support
This commit is contained in:
dswbx
2025-08-20 19:14:05 +02:00
committed by GitHub
101 changed files with 7821 additions and 311 deletions

10
.cursor/mcp.json Normal file
View File

@@ -0,0 +1,10 @@
{
"mcpServers": {
"bknd": {
"url": "http://localhost:3000/mcp",
"headers": {
"API_KEY": "value"
}
}
}
}

View File

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

View File

@@ -13,6 +13,9 @@ describe("AppServer", () => {
allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
mcp: {
enabled: false,
},
});
}
@@ -31,6 +34,9 @@ describe("AppServer", () => {
allow_methods: ["GET", "POST"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
},
mcp: {
enabled: false,
},
});
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>): ModuleBuildContext {
const { dummyConnection } = getDummyConnection();
@@ -19,6 +19,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
guard: new Guard(),
flags: Module.ctx_flags,
logger: new DebugLogger(false),
mcp: new McpServer(),
...overrides,
};
return {

View File

@@ -102,7 +102,9 @@ describe("json form", () => {
] satisfies [string, Exclude<JSONSchema, boolean>, 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,
);
}
});

View File

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

View File

@@ -69,6 +69,8 @@ const external = [
"@libsql/client",
"bknd",
/^bknd\/.*/,
"jsonv-ts",
/^jsonv-ts\/.*/,
] as const;
/**

View File

@@ -2,4 +2,5 @@
#registry = "http://localhost:4873"
[test]
coverageSkipTestFiles = true
coverageSkipTestFiles = true
console.depth = 10

View File

@@ -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();

View File

@@ -43,7 +43,8 @@
"test:e2e:adapters": "bun run e2e/adapters.ts",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report"
"test:e2e:report": "playwright show-report",
"docs:build-assets": "bun internal/docs.build-assets.ts"
},
"license": "FSL-1.1-MIT",
"dependencies": {
@@ -64,7 +65,7 @@
"hono": "4.8.3",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.3.2",
"jsonv-ts": "^0.8.1",
"kysely": "0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",

View File

@@ -1,5 +1,5 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { $console } from "bknd/utils";
import { $console, McpClient } from "bknd/utils";
import { Event } from "core/events";
import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection";
@@ -96,6 +96,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
private trigger_first_boot = false;
private _building: boolean = false;
private _systemController: SystemController | null = null;
constructor(
public connection: C,
@@ -168,11 +169,12 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build({ fetch: options?.fetch });
const { guard, server } = this.modules.ctx();
const { guard } = this.modules.ctx();
// load system controller
guard.registerPermissions(Object.values(SystemPermissions));
server.route("/api/system", new SystemController(this).getController());
this._systemController = new SystemController(this);
this._systemController.register(this);
// emit built event
$console.log("App built");
@@ -204,6 +206,10 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return this.modules.ctx().em;
}
get mcp() {
return this._systemController?._mcpServer;
}
get fetch(): Hono["fetch"] {
return this.server.fetch as any;
}
@@ -262,6 +268,17 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
}
getMcpClient() {
if (!this.mcp) {
throw new Error("MCP is not enabled");
}
return new McpClient({
url: "http://localhost/mcp",
fetch: this.server.request,
});
}
async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) {
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".

View File

@@ -1,4 +1,4 @@
import type { DB } from "bknd";
import type { DB, PrimaryFieldType } from "bknd";
import * as AuthPermissions from "auth/auth-permissions";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
@@ -87,6 +87,7 @@ export class AppAuth extends Module<AppAuthSchema> {
super.setBuilt();
this._controller = new AuthController(this);
this._controller.registerMcp();
this.ctx.server.route(this.config.basepath, this._controller.getController());
this.ctx.guard.registerPermissions(AuthPermissions);
}
@@ -176,6 +177,32 @@ export class AppAuth extends Module<AppAuthSchema> {
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;

View File

@@ -1,11 +1,20 @@
import type { SafeUser } from "bknd";
import type { DB, SafeUser } from "bknd";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
import type { AppAuth } from "auth/AppAuth";
import * as AuthPermissions from "auth/auth-permissions";
import * as DataPermissions from "data/permissions";
import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller";
import { describeRoute, jsc, s, parse, InvalidSchemaError, transformObject } from "bknd/utils";
import {
describeRoute,
jsc,
s,
parse,
InvalidSchemaError,
transformObject,
mcpTool,
} from "bknd/utils";
import type { PasswordStrategy } from "auth/authenticate/strategies";
export type AuthActionResponse = {
success: boolean;
@@ -118,6 +127,9 @@ export class AuthController extends Controller {
summary: "Get the current user",
tags: ["auth"],
}),
mcpTool("auth_me", {
noErrorCodes: [403],
}),
auth(),
async (c) => {
const claims = c.get("auth")?.user;
@@ -159,6 +171,7 @@ export class AuthController extends Controller {
summary: "Get the available authentication strategies",
tags: ["auth"],
}),
mcpTool("auth_strategies"),
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
async (c) => {
const { include_disabled } = c.req.valid("query");
@@ -188,4 +201,118 @@ export class AuthController extends Controller {
return hono;
}
override registerMcp(): void {
const { mcp } = this.auth.ctx;
const getUser = async (params: { id?: string | number; email?: string }) => {
let user: DB["users"] | undefined = undefined;
if (params.id) {
const { data } = await this.userRepo.findId(params.id);
user = data;
} else if (params.email) {
const { data } = await this.userRepo.findOne({ email: params.email });
user = data;
}
if (!user) {
throw new Error("User not found");
}
return user;
};
mcp.tool(
// @todo: needs permission
"auth_user_create",
{
description: "Create a new user",
inputSchema: s.object({
email: s.string({ format: "email" }),
password: s.string({ minLength: 8 }),
role: s
.string({
enum: Object.keys(this.auth.config.roles ?? {}),
})
.optional(),
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c);
return c.json(await this.auth.createUser(params));
},
);
mcp.tool(
// @todo: needs permission
"auth_user_token",
{
description: "Get a user token",
inputSchema: s.object({
id: s.anyOf([s.string(), s.number()]).optional(),
email: s.string({ format: "email" }).optional(),
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c);
const user = await getUser(params);
return c.json({ user, token: await this.auth.authenticator.jwt(user) });
},
);
mcp.tool(
// @todo: needs permission
"auth_user_password_change",
{
description: "Change a user's password",
inputSchema: s.object({
id: s.anyOf([s.string(), s.number()]).optional(),
email: s.string({ format: "email" }).optional(),
password: s.string({ minLength: 8 }),
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c);
const user = await getUser(params);
if (!(await this.auth.changePassword(user.id, params.password))) {
throw new Error("Failed to change password");
}
return c.json({ changed: true });
},
);
mcp.tool(
// @todo: needs permission
"auth_user_password_test",
{
description: "Test a user's password",
inputSchema: s.object({
email: s.string({ format: "email" }),
password: s.string({ minLength: 8 }),
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c);
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
const controller = pw.getController(this.auth.authenticator);
const res = await controller.request(
new Request("https://localhost/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: params.email,
password: params.password,
}),
}),
);
return c.json({ valid: res.ok });
},
);
}
}

View File

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

View File

@@ -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" },
);

View File

@@ -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<SafeUser | undefined> {
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<SafeUser | undefined> {
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) {

View File

@@ -6,3 +6,4 @@ export { user } from "./user";
export { create } from "./create";
export { copyAssets } from "./copy-assets";
export { types } from "./types";
export { mcp } from "./mcp/mcp";

View File

@@ -0,0 +1,84 @@
import type { CliCommand } from "cli/types";
import { makeAppFromEnv } from "../run";
import { getSystemMcp } from "modules/mcp/system-mcp";
import { $console, stdioTransport } from "bknd/utils";
export const mcp: CliCommand = (program) =>
program
.command("mcp")
.description("mcp server stdio transport")
.option("--config <config>", "config file")
.option("--db-url <db>", "database url, can be any valid sqlite url")
.option(
"--token <token>",
"token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable",
)
.option("--verbose", "verbose output")
.option("--log-level <level>", "log level")
.option("--force", "force enable mcp")
.action(action);
async function action(options: {
verbose?: boolean;
config?: string;
dbUrl?: string;
token?: string;
logLevel?: string;
force?: boolean;
}) {
const verbose = !!options.verbose;
const __oldConsole = { ...console };
// disable console
if (!verbose) {
$console.disable();
Object.entries(console).forEach(([key]) => {
console[key] = () => null;
});
}
const app = await makeAppFromEnv({
config: options.config,
dbUrl: options.dbUrl,
server: "node",
});
if (!app.modules.get("server").config.mcp.enabled && !options.force) {
$console.enable();
Object.assign(console, __oldConsole);
console.error("MCP is not enabled in the config, use --force to enable it");
process.exit(1);
}
const token = options.token || process.env.BEARER_TOKEN;
const server = getSystemMcp(app);
if (verbose) {
console.info(
`\n⚙ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`,
);
console.info(
`📚 Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`,
);
console.info("\nMCP server is running on STDIO transport");
}
if (options.logLevel) {
server.setLogLevel(options.logLevel as any);
}
const stdout = process.stdout;
const stdin = process.stdin;
const stderr = process.stderr;
{
using transport = stdioTransport(server, {
stdin,
stdout,
stderr,
raw: new Request("https://localhost", {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}),
});
}
}

View File

@@ -19,11 +19,15 @@ export const user: CliCommand = (program) => {
.addArgument(
new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
)
.option("--config <config>", "config file")
.option("--db-url <db>", "database url, can be any valid sqlite url")
.action(action);
};
async function action(action: "create" | "update" | "token", options: any) {
const app = await makeAppFromEnv({
config: options.config,
dbUrl: options.dbUrl,
server: "node",
});
@@ -84,9 +88,6 @@ async function create(app: App, options: any) {
async function update(app: App, options: any) {
const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const users_entity = config.entity_name as "users";
const em = app.modules.ctx().em;
const email = (await $text({
message: "Which user? Enter email",
@@ -99,7 +100,10 @@ async function update(app: App, options: any) {
})) as string;
if ($isCancel(email)) process.exit(1);
const { data: user } = await em.repository(users_entity).findOne({ email });
const { data: user } = await app.modules
.ctx()
.em.repository(config.entity_name as "users")
.findOne({ email });
if (!user) {
$log.error("User not found");
process.exit(1);
@@ -117,26 +121,10 @@ async function update(app: App, options: any) {
});
if ($isCancel(password)) process.exit(1);
try {
function togglePw(visible: boolean) {
const field = em.entity(users_entity).field("strategy_value")!;
field.config.hidden = !visible;
field.config.fillable = visible;
}
togglePw(true);
await app.modules
.ctx()
.em.mutator(users_entity)
.updateOne(user.id, {
strategy_value: await strategy.hash(password as string),
});
togglePw(false);
if (await app.module.auth.changePassword(user.id, password)) {
$log.success(`Updated user: ${c.cyan(user.email)}`);
} catch (e) {
} else {
$log.error("Error updating user");
$console.error(e);
}
}

View File

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

View File

@@ -27,7 +27,7 @@ export class SchemaObject<Schema extends TSchema = TSchema> {
) {
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<Schema extends TSchema = TSchema> {
this.throwIfRestricted(partial);
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore
const config = set(current, path, value);

View File

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

View File

@@ -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: <explanation>
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) {

View File

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

View File

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

View File

@@ -26,6 +26,20 @@ export function omitKeys<T extends object, K extends keyof T>(
return result;
}
export function pickKeys<T extends object, K extends keyof T>(
obj: T,
keys_: readonly K[],
): Pick<T, Extract<K, keyof T>> {
const keys = new Set(keys_);
const result = {} as Pick<T, Extract<K, keyof T>>;
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<T extends { [key: string]: any }>(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<T>(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 extends { [key: string]: any }>(obj: Obj): Obj {
if (!obj) return obj;
return Object.entries(obj).reduce((acc, [key, value]) => {

View File

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

View File

@@ -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<O extends IStringOptions> extends StringSchema<O> {}
export class SecretSchema<O extends s.IStringOptions> extends StringSchema<O> {}
export const secret = <O extends IStringOptions>(o?: O): SecretSchema<O> & O =>
export const secret = <O extends s.IStringOptions>(o?: O): SecretSchema<O> & O =>
new SecretSchema(o) as any;

View File

@@ -1,5 +1,4 @@
import { transformObject } from "bknd/utils";
import { Module } from "modules/Module";
import { DataController } from "./api/DataController";
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
@@ -49,10 +48,9 @@ export class AppData extends Module<AppDataConfig> {
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();

View File

@@ -1,7 +1,7 @@
import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import { jsc, s, describeRoute, schemaToSpec, omitKeys } from "bknd/utils";
import { jsc, s, describeRoute, schemaToSpec, omitKeys, pickKeys, mcpTool } from "bknd/utils";
import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema";
import type { EntityManager, EntityData } from "data/entities";
@@ -62,6 +62,11 @@ export class DataController extends Controller {
hono.get(
"/sync",
permission(DataPermissions.databaseSync),
mcpTool("data_sync", {
annotations: {
destructiveHint: true,
},
}),
describeRoute({
summary: "Sync database schema",
tags: ["data"],
@@ -77,9 +82,7 @@ export class DataController extends Controller {
),
async (c) => {
const { force, drop } = c.req.valid("query");
//console.log("force", force);
const tables = await this.em.schema().introspect();
//console.log("tables", tables);
const changes = await this.em.schema().sync({
force,
drop,
@@ -165,6 +168,7 @@ export class DataController extends Controller {
summary: "Retrieve entity info",
tags: ["data"],
}),
mcpTool("data_entity_info"),
jsc("param", s.object({ entity: entitiesEnum })),
async (c) => {
const { entity } = c.req.param();
@@ -201,7 +205,9 @@ export class DataController extends Controller {
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: make dynamic based on entity
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as number | string });
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })], {
coerce: (v) => v as number | string,
});
/**
* Function endpoints
@@ -214,6 +220,7 @@ export class DataController extends Controller {
summary: "Count entities",
tags: ["data"],
}),
mcpTool("data_entity_fn_count"),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
@@ -236,6 +243,7 @@ export class DataController extends Controller {
summary: "Check if entity exists",
tags: ["data"],
}),
mcpTool("data_entity_fn_exists"),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
@@ -268,6 +276,9 @@ export class DataController extends Controller {
(p) => pick.includes(p.name),
) as any),
];
const saveRepoQuerySchema = (pick: string[] = Object.keys(saveRepoQuery.properties)) => {
return s.object(pickKeys(saveRepoQuery.properties, pick as any));
};
hono.get(
"/:entity",
@@ -300,6 +311,12 @@ export class DataController extends Controller {
tags: ["data"],
}),
permission(DataPermissions.entityRead),
mcpTool("data_entity_read_one", {
inputSchema: {
param: s.object({ entity: entitiesEnum, id: idType }),
query: saveRepoQuerySchema(["offset", "sort", "select"]),
},
}),
jsc(
"param",
s.object({
@@ -375,6 +392,12 @@ export class DataController extends Controller {
tags: ["data"],
}),
permission(DataPermissions.entityRead),
mcpTool("data_entity_read_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
json: fnQuery,
},
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery, { skipOpenAPI: true }),
async (c) => {
@@ -400,6 +423,7 @@ export class DataController extends Controller {
tags: ["data"],
}),
permission(DataPermissions.entityCreate),
mcpTool("data_entity_insert"),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
async (c) => {
@@ -427,6 +451,15 @@ export class DataController extends Controller {
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
mcpTool("data_entity_update_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
json: s.object({
update: s.object({}),
where: s.object({}),
}),
},
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc(
"json",
@@ -458,6 +491,7 @@ export class DataController extends Controller {
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
mcpTool("data_entity_update_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})),
async (c) => {
@@ -480,6 +514,7 @@ export class DataController extends Controller {
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
mcpTool("data_entity_delete_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => {
const { entity, id } = c.req.valid("param");
@@ -500,6 +535,12 @@ export class DataController extends Controller {
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
mcpTool("data_entity_delete_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
json: s.object({}),
},
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
@@ -516,4 +557,35 @@ export class DataController extends Controller {
return hono;
}
override registerMcp() {
this.ctx.mcp
.resource(
"data_entities",
"bknd://data/entities",
(c) => c.json(c.context.ctx().em.toJSON().entities),
{
title: "Entities",
description: "Retrieve all entities",
},
)
.resource(
"data_relations",
"bknd://data/relations",
(c) => c.json(c.context.ctx().em.toJSON().relations),
{
title: "Relations",
description: "Retrieve all relations",
},
)
.resource(
"data_indices",
"bknd://data/indices",
(c) => c.json(c.context.ctx().em.toJSON().indices),
{
title: "Indices",
description: "Retrieve all indices",
},
);
}
}

View File

@@ -9,6 +9,7 @@ export {
type ConnQueryResults,
customIntrospector,
} from "./Connection";
export { DummyConnection } from "./DummyConnection";
// sqlite
export { SqliteConnection } from "./sqlite/SqliteConnection";

View File

@@ -3,14 +3,16 @@ import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
import { FieldClassMap } from "data/fields";
import { RelationClassMap, RelationFieldClassMap } from "data/relations";
import { entityConfigSchema, entityTypes } from "data/entities";
import { primaryFieldTypes } from "./fields";
import { primaryFieldTypes, baseFieldConfigSchema } from "./fields";
import { s } from "bknd/utils";
import { $object, $record } from "modules/mcp";
export const FIELDS = {
...FieldClassMap,
...RelationFieldClassMap,
media: { schema: mediaFieldConfigSchema, field: MediaField },
};
export const FIELD_TYPES = Object.keys(FIELDS);
export type FieldType = keyof typeof FIELDS;
export const RELATIONS = RelationClassMap;
@@ -28,17 +30,30 @@ export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => {
);
});
export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
export const entityFields = s.record(fieldsSchema);
export const entityFields = s.record(fieldsSchema, { default: {} });
export type TAppDataField = s.Static<typeof fieldsSchema>;
export type TAppDataEntityFields = s.Static<typeof entityFields>;
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<typeof entitiesSchema>;
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<typeof dataConfigSchema>;

View File

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

View File

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

View File

@@ -1,12 +1,11 @@
import { s, isObject, $console } from "bknd/utils";
import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder";
import type { anyOf, CoercionOptions, Schema } from "jsonv-ts";
// -------
// helpers
const stringIdentifier = s.string({
// allow "id", "id,title" but not "id," or "not allowed"
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
//pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
});
const stringArray = s.anyOf(
[
@@ -24,7 +23,7 @@ const stringArray = s.anyOf(
if (v.includes(",")) {
return v.split(",");
}
return [v];
return [v].filter(Boolean);
}
return [];
},
@@ -79,6 +78,8 @@ const where = s.anyOf([s.string(), s.object({})], {
},
],
coerce: (value: unknown) => {
if (value === undefined || value === null || value === "") return {};
const q = typeof value === "string" ? JSON.parse(value) : value;
return WhereBuilder.convert(q);
},
@@ -96,9 +97,9 @@ export type RepoWithSchema = Record<
}
>;
const withSchema = <Type = unknown>(self: Schema): Schema<{}, Type, Type> =>
const withSchema = <Type = unknown>(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") {

View File

@@ -35,6 +35,7 @@ export type { BkndConfig } from "bknd/adapter";
export * as middlewares from "modules/middlewares";
export { registries } from "modules/registries";
export { getSystemMcp } from "modules/mcp/system-mcp";
/**
* Core
@@ -131,6 +132,7 @@ export {
BaseIntrospector,
Connection,
customIntrospector,
DummyConnection,
type FieldSpec,
type IndexSpec,
type DbFunctions,

View File

@@ -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: {},

View File

@@ -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<any>): 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 {}
}

View File

@@ -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<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type ModuleBuildContextMcpContext = {
app: App;
ctx: () => ModuleBuildContext;
};
export type ModuleBuildContext = {
connection: Connection;
server: Hono<ServerEnv>;
@@ -19,6 +24,7 @@ export type ModuleBuildContext = {
logger: DebugLogger;
flags: (typeof Module)["ctx_flags"];
helper: ModuleHelper;
mcp: McpServer<ModuleBuildContextMcpContext>;
};
export abstract class Module<Schema extends object = object> {

View File

@@ -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<ModuleBuildContext, "helper">) {}
@@ -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,
);
}
}
}

View File

@@ -1,8 +1,16 @@
import { mark, stripMark, $console, s, objectEach, transformObject } from "bknd/utils";
import {
mark,
stripMark,
$console,
s,
objectEach,
transformObject,
McpServer,
DebugLogger,
} from "bknd/utils";
import { Guard } from "auth/authorize/Guard";
import { env } from "core/env";
import { BkndError } from "core/errors";
import { DebugLogger } from "core/utils/DebugLogger";
import { EventManager, Event } from "core/events";
import * as $diff from "core/object/diff";
import type { Connection } from "data/connection";
@@ -144,6 +152,7 @@ export class ModuleManager {
server!: Hono<ServerEnv>;
emgr!: EventManager;
guard!: Guard;
mcp!: ModuleBuildContext["mcp"];
private _version: number = 0;
private _built = false;
@@ -271,6 +280,14 @@ export class ModuleManager {
? this.em.clear()
: new EntityManager([], this.connection, [], [], this.emgr);
this.guard = new Guard();
this.mcp = new McpServer(undefined as any, {
app: new Proxy(this, {
get: () => {
throw new Error("app is not available in mcp context");
},
}) as any,
ctx: () => this.ctx(),
});
}
const ctx = {
@@ -281,6 +298,7 @@ export class ModuleManager {
guard: this.guard,
flags: Module.ctx_flags,
logger: this.logger,
mcp: this.mcp,
};
return {
@@ -702,7 +720,7 @@ export class ModuleManager {
return {
version: this.version(),
...schemas,
};
} as { version: number } & ModuleSchemas;
}
toJSON(secrets?: boolean): { version: number } & ModuleConfigs {

View File

@@ -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<P, O>
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<ObjectToolSchema>) {
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<ObjectToolSchema>) {
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<ObjectToolSchema>): Tool<any, any, any>[] {
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<ObjectToolSchemaOptions, O>,
): ObjectToolSchema<P, O> & O => {
return new ObjectToolSchema(name, properties, options) as any;
};

View File

@@ -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<RecordToolAdditionalOptions> {}
const opts = Symbol.for("bknd-mcp-record-opts");
export class RecordToolSchema<
AP extends s.Schema,
O extends RecordToolSchemaOptions = RecordToolSchemaOptions,
>
extends s.RecordSchema<AP, O>
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<RecordToolAdditionalOptions> {
return this[mcpSchemaSymbol];
}
private getNewSchema(fallback: s.Schema = this.additionalProperties) {
return this[opts].new_schema ?? this.additionalProperties ?? fallback;
}
private toolGet(node: s.Node<RecordToolSchema<AP, O>>) {
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<RecordToolSchema<AP, O>>) {
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<RecordToolSchema<AP, O>>) {
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<RecordToolSchema<AP, O>>) {
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<RecordToolSchema<AP, O>>): Tool<any, any, any>[] {
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<any, any, any>[];
}
}
export const $record = <const AP extends s.Schema, const O extends RecordToolSchemaOptions>(
name: string,
ap: AP,
options?: s.StrictOptions<RecordToolSchemaOptions, O>,
new_schema?: s.Schema,
): RecordToolSchema<AP, O> => new RecordToolSchema(name, ap, options, new_schema) as any;

View File

@@ -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<S>) => {
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<S>) => {
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<S>) => {
const { tools = [] } = mcp.options;
return [toolGet(node), toolUpdate(node), ...tools];
};
return Object.assign(schema, {
[mcpSchemaSymbol]: mcp,
getTools,
});
};

View File

@@ -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<any, any, any>[];
resources?: Resource<any, any, any, any>[];
}
export type SchemaWithMcpOptions<AdditionalOptions = {}> = {
mcp?: McpToolOptions & AdditionalOptions;
};
export type AppToolContext = {
app: App;
ctx: () => ModuleBuildContext;
};
export type AppToolHandlerCtx = ToolHandlerCtx<AppToolContext>;
export interface McpSchema extends s.Schema {
getTools(node: s.Node<any>): Tool<any, any, any>[];
}
export class McpSchemaHelper<AdditionalOptions = {}> {
cleanSchema: s.ObjectSchema<any, any>;
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<any, any>;
}
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,
},
};
}
}

View File

@@ -0,0 +1,4 @@
export * from "./$object";
export * from "./$record";
export * from "./$schema";
export * from "./McpSchemaHelper";

View File

@@ -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<McpSchema>[];
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,
);
}

View File

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

View File

@@ -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<any, any>,
props: (instance: s.Schema | unknown) => boolean,
): s.TProperties {
const properties = { ...input.properties };
return transformObject(properties, (value, key) => {
if (props(value)) {
return undefined;
}
return value;
});
}

View File

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

View File

@@ -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/*");
}

View File

@@ -1,25 +1,35 @@
import { Exception } from "core/errors";
import { isDebug } from "core/env";
import { $console, s } from "bknd/utils";
import { $object } from "modules/mcp";
import { cors } from "hono/cors";
import { Module } from "modules/Module";
import { AuthException } from "auth/errors";
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const;
export const serverConfigSchema = s.strictObject({
cors: s.strictObject({
origin: s.string({ default: "*" }),
allow_methods: s.array(s.string({ enum: serverMethods }), {
default: serverMethods,
uniqueItems: true,
export const serverConfigSchema = $object(
"config_server",
{
cors: s.strictObject({
origin: s.string({ default: "*" }),
allow_methods: s.array(s.string({ enum: serverMethods }), {
default: serverMethods,
uniqueItems: true,
}),
allow_headers: s.array(s.string(), {
default: ["Content-Type", "Content-Length", "Authorization", "Accept"],
}),
allow_credentials: s.boolean({ default: true }),
}),
allow_headers: s.array(s.string(), {
default: ["Content-Type", "Content-Length", "Authorization", "Accept"],
mcp: s.strictObject({
enabled: s.boolean({ default: false }),
}),
allow_credentials: s.boolean({ default: true }),
}),
});
},
{
description: "Server configuration",
},
);
export type AppServerConfig = s.Static<typeof serverConfigSchema>;

View File

@@ -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<Key extends ModuleKey = ModuleKey> = {
success: true;
@@ -43,6 +48,8 @@ export type SchemaResponse = {
};
export class SystemController extends Controller {
_mcpServer: McpServer | null = null;
constructor(private readonly app: App) {
super();
}
@@ -51,6 +58,53 @@ export class SystemController extends Controller {
return this.app.modules.ctx();
}
register(app: App) {
app.server.route("/api/system", this.getController());
if (!this.app.modules.get("server").config.mcp.enabled) {
return;
}
this.registerMcp();
this._mcpServer = getSystemMcp(app);
this._mcpServer.onNotification((message) => {
if (message.method === "notification/message") {
const consoleMap = {
emergency: "error",
alert: "error",
critical: "error",
error: "error",
warning: "warn",
notice: "log",
info: "info",
debug: "debug",
};
const level = consoleMap[message.params.level];
if (!level) return;
$console[level](message.params.message);
}
});
app.server.use(
mcpMiddleware({
server: this._mcpServer,
sessionsEnabled: true,
debug: {
logLevel: "debug",
explainEndpoint: true,
},
endpoint: {
path: "/mcp",
// @ts-ignore
_init: isNode() ? { duplex: "half" } : {},
},
}),
);
}
private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares;
// don't add auth again, it's already added in getController
@@ -77,6 +131,11 @@ export class SystemController extends Controller {
summary: "Get the config for a module",
tags: ["system"],
}),
mcpTool("system_config", {
annotations: {
readOnlyHint: true,
},
}), // @todo: ":module" gets not removed
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
jsc("query", s.object({ secrets: s.boolean().optional() })),
async (c) => {
@@ -283,6 +342,7 @@ export class SystemController extends Controller {
summary: "Build the app",
tags: ["system"],
}),
mcpTool("system_build"),
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
async (c) => {
const options = c.req.valid("query") as Record<string, boolean>;
@@ -298,6 +358,7 @@ export class SystemController extends Controller {
hono.get(
"/ping",
mcpTool("system_ping"),
describeRoute({
summary: "Ping the server",
tags: ["system"],
@@ -307,13 +368,17 @@ export class SystemController extends Controller {
hono.get(
"/info",
mcpTool("system_info"),
describeRoute({
summary: "Get the server info",
tags: ["system"],
}),
(c) =>
c.json({
version: c.get("app")?.version(),
version: {
config: c.get("app")?.version(),
bknd: getVersion(),
},
runtime: getRuntimeKey(),
connection: {
name: this.app.em.connection.name,
@@ -328,19 +393,6 @@ export class SystemController extends Controller {
},
origin: new URL(c.req.raw.url).origin,
plugins: Array.from(this.app.plugins.keys()),
walk: {
auth: [
...c
.get("app")
.getSchema()
.auth.walk({ data: c.get("app").toJSON(true).auth }),
]
.filter((n) => n.schema instanceof SecretSchema)
.map((n) => ({
...n,
schema: n.schema.constructor.name,
})),
},
}),
);
@@ -357,4 +409,54 @@ export class SystemController extends Controller {
return hono;
}
override registerMcp() {
const { mcp } = this.app.modules.ctx();
const { version, ...appConfig } = this.app.toJSON();
mcp.resource("system_config", "bknd://system/config", async (c) => {
await c.context.ctx().helper.throwUnlessGranted(SystemPermissions.configRead, c);
return c.json(this.app.toJSON(), {
title: "System Config",
});
})
.resource(
"system_config_module",
"bknd://system/config/{module}",
async (c, { module }) => {
await this.ctx.helper.throwUnlessGranted(SystemPermissions.configRead, c);
const m = this.app.modules.get(module as any) as Module;
return c.json(m.toJSON(), {
title: `Config for ${module}`,
});
},
{
list: Object.keys(appConfig),
},
)
.resource("system_schema", "bknd://system/schema", async (c) => {
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
return c.json(this.app.getSchema(), {
title: "System Schema",
});
})
.resource(
"system_schema_module",
"bknd://system/schema/{module}",
async (c, { module }) => {
await this.ctx.helper.throwUnlessGranted(SystemPermissions.schemaRead, c);
const m = this.app.modules.get(module as any);
return c.json(m.getSchema().toJSON(), {
title: `Schema for ${module}`,
});
},
{
list: Object.keys(this.app.getSchema()),
},
);
}
}

View File

@@ -2,6 +2,35 @@ import { TbCopy } from "react-icons/tb";
import { JsonView } from "react-json-view-lite";
import { twMerge } from "tailwind-merge";
import { IconButton } from "../buttons/IconButton";
import ErrorBoundary from "ui/components/display/ErrorBoundary";
import { forwardRef, useImperativeHandle, useState } from "react";
export type JsonViewerProps = {
json: object | null;
title?: string;
expand?: number;
showSize?: boolean;
showCopy?: boolean;
copyIconProps?: any;
className?: string;
};
const style = {
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
container: "ml-[-10px]",
label: "text-primary/90 font-bold font-mono mr-2",
stringValue: "text-emerald-600 dark:text-emerald-500 font-mono select-text",
numberValue: "text-sky-500 dark:text-sky-400 font-mono",
nullValue: "text-zinc-400 font-mono",
undefinedValue: "text-zinc-400 font-mono",
otherValue: "text-zinc-400 font-mono",
booleanValue: "text-orange-500 dark:text-orange-400 font-mono",
punctuation: "text-zinc-400 font-bold font-mono m-0.5",
collapsedContent: "text-zinc-400 font-mono after:content-['...']",
collapseIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['▾'] mr-1.5",
expandIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['▸'] mr-1.5",
noQuotesForStringValues: false,
} as any;
export const JsonViewer = ({
json,
@@ -11,16 +40,8 @@ export const JsonViewer = ({
showCopy = false,
copyIconProps = {},
className,
}: {
json: object;
title?: string;
expand?: number;
showSize?: boolean;
showCopy?: boolean;
copyIconProps?: any;
className?: string;
}) => {
const size = showSize ? JSON.stringify(json).length : undefined;
}: JsonViewerProps) => {
const size = showSize ? (json === null ? 0 : (JSON.stringify(json)?.length ?? 0)) : undefined;
const showContext = size || title || showCopy;
function onCopy() {
@@ -31,9 +52,10 @@ export const JsonViewer = ({
<div className={twMerge("bg-primary/5 py-3 relative overflow-hidden", className)}>
{showContext && (
<div className="absolute right-4 top-3 font-mono text-zinc-400 flex flex-row gap-2 items-center">
{(title || size) && (
{(title || size !== undefined) && (
<div className="flex flex-row">
{title && <span>{title}</span>} {size && <span>({size} Bytes)</span>}
{title && <span>{title}</span>}{" "}
{size !== undefined && <span>({size} Bytes)</span>}
</div>
)}
{showCopy && (
@@ -43,30 +65,66 @@ export const JsonViewer = ({
)}
</div>
)}
<JsonView
data={json}
shouldExpandNode={(level) => 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
}
/>
<ErrorBoundary>
<JsonView
data={json as any}
shouldExpandNode={(level) => level < expand}
style={style}
/>
</ErrorBoundary>
</div>
);
};
export type JsonViewerTabsProps = Omit<JsonViewerProps, "json"> & {
selected?: string;
tabs: {
[key: string]: JsonViewerProps & {
enabled?: boolean;
};
};
};
export type JsonViewerTabsRef = {
setSelected: (selected: string) => void;
};
export const JsonViewerTabs = forwardRef<JsonViewerTabsRef, JsonViewerTabsProps>(
({ 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 (
<div className="flex flex-col bg-primary/5 rounded-md">
<div className="flex flex-row gap-4 border-b px-3 border-primary/10">
{Object.keys(tabs).map((key) => (
<button
key={key}
type="button"
className={twMerge(
"flex flex-row text-sm cursor-pointer py-3 pt-3.5 px-1 border-b border-transparent -mb-px transition-opacity",
selected === key ? "border-primary" : "opacity-50 hover:opacity-70",
)}
onClick={() => setSelected(key)}
>
<span className="font-mono leading-none">{key}</span>
</button>
))}
</div>
{/* @ts-ignore */}
<JsonViewer
className="bg-transparent"
{...defaultProps}
{...tabs[selected as any]}
title={undefined}
/>
</div>
);
},
);

View File

@@ -8,6 +8,7 @@ export type EmptyProps = {
primary?: ButtonProps;
secondary?: ButtonProps;
className?: string;
children?: React.ReactNode;
};
export const Empty: React.FC<EmptyProps> = ({
Icon = undefined,
@@ -16,6 +17,7 @@ export const Empty: React.FC<EmptyProps> = ({
primary,
secondary,
className,
children,
}) => (
<div className={twMerge("flex flex-col h-full w-full justify-center items-center", className)}>
<div className="flex flex-col gap-3 items-center max-w-80">
@@ -27,6 +29,7 @@ export const Empty: React.FC<EmptyProps> = ({
<div className="mt-1.5 flex flex-row gap-2">
{secondary && <Button variant="default" {...secondary} />}
{primary && <Button variant="primary" {...primary} />}
{children}
</div>
</div>
</div>

View File

@@ -40,7 +40,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
<BaseError>{this.props.fallback}</BaseError>
);
}
return <BaseError>Error1</BaseError>;
return <BaseError>{this.state.error?.message ?? "Unknown error"}</BaseError>;
}
override render() {
@@ -61,7 +61,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
}
const BaseError = ({ children }: { children: ReactNode }) => (
<div className="bg-red-700 text-white py-1 px-2 rounded-md leading-none font-mono">
<div className="bg-red-700 text-white py-1 px-2 rounded-md leading-tight font-mono">
{children}
</div>
);

View File

@@ -5,8 +5,15 @@ import { twMerge } from "tailwind-merge";
import * as Formy from "ui/components/form/Formy";
import { useEvent } from "ui/hooks/use-event";
import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field";
import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form";
import {
FormContextOverride,
useDerivedFieldContext,
useFormContext,
useFormError,
useFormValue,
} from "./Form";
import { getLabel, getMultiSchemaMatched } from "./utils";
import { FieldWrapper } from "ui/components/form/json-schema-form/FieldWrapper";
export type AnyOfFieldRootProps = {
path?: string;
@@ -47,7 +54,17 @@ const Root = ({ path = "", children }: AnyOfFieldRootProps) => {
const errors = useFormError(path, { strict: true });
if (!schema) return `AnyOfField(${path}): no schema ${pointer}`;
const [_selected, setSelected] = useAtom(selectedAtom);
const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null;
const {
options: { anyOfNoneSelectedMode },
} = useFormContext();
const selected =
_selected !== null
? _selected
: matchedIndex > -1
? matchedIndex
: anyOfNoneSelectedMode === "first"
? 0
: null;
const select = useEvent((index: number | null) => {
setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined);
@@ -117,15 +134,27 @@ const Select = () => {
const Field = ({ name, label, ...props }: Partial<FormFieldProps>) => {
const { selected, selectedSchema, path, errors } = useAnyOfContext();
if (selected === null) return null;
return (
<FormContextOverride prefix={path} schema={selectedSchema}>
<div className={twMerge(errors.length > 0 && "bg-red-500/10")}>
<FormField key={`${path}_${selected}`} name={""} label={false} {...props} />
{/* another wrap is required for primitive schemas */}
<AnotherField key={`${path}_${selected}`} label={false} {...props} />
</div>
</FormContextOverride>
);
};
const AnotherField = (props: Partial<FormFieldProps>) => {
const { value } = useFormValue("");
const inputProps = {
// @todo: check, potentially just provide value
value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined,
};
return <FormField name={""} label={false} {...props} inputProps={inputProps} />;
};
export const AnyOf = {
Root,
Select,

View File

@@ -46,6 +46,7 @@ type FormState<Data = any> = {
type FormOptions = {
debug?: boolean;
keepEmpty?: boolean;
anyOfNoneSelectedMode?: "none" | "first";
};
export type FormContext<Data> = {
@@ -190,7 +191,7 @@ export function Form<
root: "",
path: "",
}),
[schema, initialValues],
[schema, initialValues, options],
) as any;
return (

View File

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

View File

@@ -1,4 +1,4 @@
import * as s from "jsonv-ts";
import { s } from "bknd/utils";
import type {
CustomValidator,

View File

@@ -19,15 +19,9 @@ import { appShellStore } from "ui/store";
import { useLocation } from "wouter";
export function Root({ children }: { children: React.ReactNode }) {
const sidebarWidth = appShellStore((store) => store.sidebarWidth);
return (
<AppShellProvider>
<div
id="app-shell"
data-shell="root"
className="flex flex-1 flex-col select-none h-dvh"
style={{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
>
<div id="app-shell" data-shell="root" className="flex flex-1 flex-col select-none h-dvh">
{children}
</div>
</AppShellProvider>
@@ -97,10 +91,24 @@ export function Main({ children }) {
);
}
export function Sidebar({ children }) {
const open = appShellStore((store) => store.sidebarOpen);
const close = appShellStore((store) => store.closeSidebar);
export function Sidebar({
children,
name = "default",
handle = "right",
minWidth,
maxWidth,
}: {
children: React.ReactNode;
name?: string;
handle?: "right" | "left";
minWidth?: number;
maxWidth?: number;
}) {
const open = appShellStore((store) => store.sidebars[name]?.open);
const close = appShellStore((store) => store.closeSidebar(name));
const width = appShellStore((store) => store.sidebars[name]?.width ?? 350);
const ref = useClickOutside(close, ["mouseup", "touchend"]); //, [document.getElementById("header")]);
const sidebarRef = useRef<HTMLDivElement>(null!);
const [location] = useLocation();
const closeHandler = () => {
@@ -115,16 +123,35 @@ export function Sidebar({ children }) {
return (
<>
{handle === "left" && (
<SidebarResize
name={name}
handle={handle}
sidebarRef={sidebarRef}
minWidth={minWidth}
maxWidth={maxWidth}
/>
)}
<aside
data-shell="sidebar"
className="hidden md:flex flex-col basis-[var(--sidebar-width)] flex-shrink-0 flex-grow-0 h-full bg-muted/10"
ref={sidebarRef}
className="hidden md:flex flex-col flex-shrink-0 flex-grow-0 h-full bg-muted/10"
style={{ width }}
>
{children}
</aside>
<SidebarResize />
{handle === "right" && (
<SidebarResize
name={name}
handle={handle}
sidebarRef={sidebarRef}
minWidth={minWidth}
maxWidth={maxWidth}
/>
)}
<div
data-open={open}
className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm"
className="absolute w-full md:hidden data-[open=true]:translate-x-0 translate-x-[-100%] transition-transform z-10 backdrop-blur-sm max-w-[90%]"
>
<aside
ref={ref}
@@ -138,30 +165,36 @@ export function Sidebar({ children }) {
);
}
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<HTMLDivElement>;
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,7 +212,7 @@ const SidebarResize = () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, startX, startWidth]);
}, [isResizing, start, startWidth, minWidth, maxWidth]);
return (
<div
@@ -334,6 +367,34 @@ export const SectionHeaderTabs = ({ title, items }: SectionHeaderTabsProps) => {
);
};
export function MaxHeightContainer(props: ComponentPropsWithoutRef<"div">) {
const scrollRef = useRef<React.ElementRef<"div">>(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 (
<div ref={scrollRef} style={{ height: `${height - offset}px` }} {...props}>
{props.children}
</div>
);
}
export function Scrollable({
children,
initialOffset = 64,
@@ -346,7 +407,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);
}
}

View File

@@ -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 <IconButton id="toggle-sidebar" size="lg" Icon={open ? TbX : TbMenu2} onClick={toggle} />;
}
@@ -132,7 +133,7 @@ export function Header({ hasSidebar = true }) {
<HeaderNavigation />
<div className="flex flex-grow" />
<div className="flex md:hidden flex-row items-center pr-2 gap-2">
<SidebarToggler />
<SidebarToggler name="default" />
<UserMenu />
</div>
<div className="hidden md:flex flex-row items-center px-4 gap-2">
@@ -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 });

View File

@@ -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 {
@@ -115,3 +115,10 @@ body,
@apply bg-primary/25;
}
}
@utility debug {
@apply border-red-500 border;
}
@utility debug-blue {
@apply border-blue-500 border;
}

View File

@@ -124,7 +124,7 @@ function DataEntityListImpl({ params }) {
</>
}
>
{entity.label}
<AppShell.SectionHeaderTitle>{entity.label}</AppShell.SectionHeaderTitle>
</AppShell.SectionHeader>
<AppShell.Scrollable key={entity.name}>
<div className="flex flex-col flex-grow p-3 gap-3">

View File

@@ -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({
<SettingsRoutes />
</Suspense>
</Route>
<Route path="/tools" nest>
<Suspense fallback={null}>
<ToolsRoutes />
</Suspense>
</Route>
<Route path="*" component={NotFound} />
</Switch>

View File

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

View File

@@ -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 }) {
</div>
</AppShell.Scrollable>
</AppShell.Sidebar>
<AppShell.Main>{children}</AppShell.Main>
<AppShell.Main key={window.location.href}>
<ErrorBoundary key={window.location.href}>{children}</ErrorBoundary>
</AppShell.Main>
</>
);
}

View File

@@ -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 (
<Scrollable>
<div className="flex flex-col p-3">
<Form schema={_schema.auth.toJSON()} options={formOptions} />
{/* <Form schema={_schema.auth.toJSON()} options={formOptions} /> */}
<Form
options={{
anyOfNoneSelectedMode: "first",
debug: true,
}}
initialValues={{ isd: "1", nested2: { name: "hello" } }}
schema={s
.object({
isd: s
.anyOf([s.string({ title: "String" }), s.number({ title: "Number" })])
.optional(),
email: s.string({ format: "email" }).optional(),
nested: s
.object({
name: s.string(),
})
.optional(),
nested2: s
.anyOf([s.object({ name: s.string() }), s.object({ age: s.number() })])
.optional(),
})
.toJSON()}
/>
{/*<Form
onChange={(data) => console.log("change", data)}

View File

@@ -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 (
<>
<Route path="/" component={ToolsIndex} />
<Route path="/mcp" component={ToolsMcp} />
</>
);
}
function ToolsIndex() {
return <Empty title="Tools" description="Select a tool to continue." />;
}

View File

@@ -0,0 +1,15 @@
export const McpIcon = () => (
<svg
fill="currentColor"
fillRule="evenodd"
height="1em"
style={{ flex: "none", lineHeight: "1" }}
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<title>ModelContextProtocol</title>
<path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z" />
<path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z" />
</svg>
);

View File

@@ -0,0 +1,77 @@
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";
export default function ToolsMcp() {
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"));
if (!config.server.mcp.enabled) {
return (
<Empty
title="MCP not enabled"
description="Please enable MCP in the settings to continue."
/>
);
}
return (
<div className="flex flex-col flex-grow">
<AppShell.SectionHeader>
<div className="flex flex-row gap-4 items-center">
<McpIcon />
<AppShell.SectionHeaderTitle className="whitespace-nowrap truncate">
MCP UI
</AppShell.SectionHeaderTitle>
<div className="hidden md:flex flex-row gap-2 items-center bg-primary/5 rounded-full px-3 pr-3.5 py-2">
<TbWorld />
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-mono leading-none">
{window.location.origin + "/mcp"}
</span>
</div>
</div>
</div>
</AppShell.SectionHeader>
<div className="flex h-full">
<AppShell.Sidebar>
<AppShell.MaxHeightContainer className="overflow-y-scroll md:overflow-auto">
<Tools.Sidebar open={feature === "tools"} toggle={() => setFeature("tools")} />
<AppShell.SectionHeaderAccordionItem
title="Resources"
open={feature === "resources"}
toggle={() => setFeature("resources")}
>
<div className="flex flex-col flex-grow p-3 gap-3 justify-center items-center opacity-40">
<i>Resources</i>
</div>
</AppShell.SectionHeaderAccordionItem>
</AppShell.MaxHeightContainer>
</AppShell.Sidebar>
{feature === "tools" && <Tools.Content />}
{!content && (
<Empty title="No tool selected" description="Please select a tool to continue.">
<Button
variant="primary"
onClick={() => openSidebar()}
className="block md:hidden"
>
Open Tools
</Button>
</Empty>
)}
</div>
</div>
);
}

View File

@@ -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: true,
},
(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 }),
}),
),
);

View File

@@ -0,0 +1,221 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getClient, 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 { 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";
export function Sidebar({ open, toggle }) {
const client = getClient();
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<string>("");
const handleRefresh = useCallback(async () => {
setLoading(true);
const res = await client.listTools();
if (res) setTools(res.tools);
setLoading(false);
}, []);
useEffect(() => {
handleRefresh();
}, []);
return (
<AppShell.SectionHeaderAccordionItem
title="Tools"
open={open}
toggle={toggle}
renderHeaderRight={() => (
<div className="flex flex-row gap-2 items-center">
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
{tools.length}
</span>
<IconButton Icon={TbRefresh} disabled={!open || loading} onClick={handleRefresh} />
</div>
)}
>
<div className="flex flex-col flex-grow p-3 gap-3">
<Formy.Input
type="text"
placeholder="Search tools"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<nav className="flex flex-col flex-1 gap-1">
{tools
.filter((tool) => tool.name.toLowerCase().includes(query.toLowerCase()))
.map((tool) => {
return (
<AppShell.SidebarLink
key={tool.name}
className={twMerge(
"flex flex-col items-start h-auto py-3 gap-px",
content?.name === tool.name ? "active" : "",
)}
onClick={() => {
setContent(tool);
closeSidebar();
}}
>
<span className="font-mono">{tool.name}</span>
<span className="text-sm text-primary/50">{tool.description}</span>
</AppShell.SidebarLink>
);
})}
</nav>
</div>
</AppShell.SectionHeaderAccordionItem>
);
}
export function Content() {
const content = useMcpStore((state) => state.content);
const addHistory = useMcpStore((state) => state.addHistory);
const [payload, setPayload] = useState<object>(getTemplate(content?.inputSchema));
const [result, setResult] = useState<object | null>(null);
const historyVisible = useMcpStore((state) => state.historyVisible);
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
const client = getClient();
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
const hasInputSchema =
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
useEffect(() => {
setPayload(getTemplate(content?.inputSchema));
setResult(null);
}, [content]);
const handleSubmit = useCallback(async () => {
if (!content?.name) return;
const request = {
name: content.name,
arguments: payload,
};
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 (
<div className="flex flex-grow flex-col">
<AppShell.SectionHeader
className="max-w-full min-w-0 debug"
right={
<div className="flex flex-row gap-2">
<IconButton
Icon={historyVisible ? TbHistoryOff : TbHistory}
onClick={() => setHistoryVisible(!historyVisible)}
/>
<Button
type="button"
disabled={!content?.name}
variant="primary"
onClick={handleSubmit}
className="whitespace-nowrap"
>
Call Tool
</Button>
</div>
}
>
<AppShell.SectionHeaderTitle className="leading-tight">
<span className="opacity-50">
Tools <span className="opacity-70">/</span>
</span>{" "}
<span className="truncate">{content?.name}</span>
</AppShell.SectionHeaderTitle>
</AppShell.SectionHeader>
<div className="flex flex-grow flex-row w-full">
<div className="flex flex-grow flex-col w-full">
<AppShell.Scrollable>
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
<p className="text-primary/80">{content?.description}</p>
{hasInputSchema && (
<Form
schema={{
title: "InputSchema",
...content?.inputSchema,
}}
initialValues={payload}
hiddenSubmit={false}
onChange={(value) => {
setPayload(value);
}}
/>
)}
<JsonViewerTabs
ref={jsonViewerTabsRef}
expand={9}
showCopy
showSize
tabs={{
Arguments: { json: payload, title: "Payload", enabled: hasInputSchema },
Result: { json: readableResult, title: "Result" },
"Tool Configuration": {
json: content ?? null,
title: "Tool Configuration",
},
}}
/>
</div>
</AppShell.Scrollable>
</div>
{historyVisible && (
<AppShell.Sidebar name="right" handle="left" maxWidth={window.innerWidth * 0.25}>
<History />
</AppShell.Sidebar>
)}
</div>
</div>
);
}
const History = () => {
const history = useMcpStore((state) => state.history.slice(0, 50));
return (
<>
<AppShell.SectionHeader>History</AppShell.SectionHeader>
<AppShell.Scrollable>
<div className="flex flex-col flex-grow p-3 gap-1">
{history.map((item, i) => (
<JsonViewer
key={`${item.type}-${i}`}
json={item.data}
title={item.type}
expand={1}
/>
))}
</div>
</AppShell.Scrollable>
</>
);
};

View File

@@ -0,0 +1,20 @@
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
import { Draft2019 } from "json-schema-library";
const clients = new Map<string, McpClient>();
export function getClient(
{ url, ...opts }: McpClientConfig = { url: window.location.origin + "/mcp" },
) {
if (!clients.has(String(url))) {
clients.set(String(url), new McpClient({ url, ...opts }));
}
return clients.get(String(url))!;
}
export function getTemplate(schema: object) {
if (!schema || schema === undefined || schema === null) return undefined;
const lib = new Draft2019(schema);
return lib.getTemplate(undefined, schema);
}

View File

@@ -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<string, SidebarState>,
},
(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,
},
},
};
},
},
),
);

View File

@@ -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": [

View File

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

View File

@@ -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.1",
"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.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-kqt1OHZ4WM92PDAxySZeGGzccZr6q5YdKpM8c7QWkwGoaa1azwTG5lV9SN3PT4kVgI0OYFDr3OGkgCszLQ+WPw=="],
"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=="],

View File

@@ -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 (
<DocsPage
toc={page.data.toc}
toc={toc}
full={page.data.full}
tableOfContent={{
style: "clerk",

View File

@@ -0,0 +1,55 @@
import type { Tool } from "jsonv-ts/mcp";
import components from "fumadocs-ui/mdx";
import { TypeTable } from "fumadocs-ui/components/type-table";
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
import type { JSONSchemaDefinition } from "jsonv-ts";
export const slugify = (s: string) => s.toLowerCase().replace(/ /g, "-");
export const indent = (s: string, indent = 2) => s.replace(/^/gm, " ".repeat(indent));
export function McpTool({ tool }: { tool: ReturnType<Tool["toJSON"]> }) {
return (
<div>
<components.h3 id={slugify(tool.name)}>
<code>{tool.name}</code>
</components.h3>
<p>{tool.description}</p>
<JsonSchemaTypeTable schema={tool.inputSchema} />
</div>
);
}
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 ? (
<TypeTable
type={Object.fromEntries(
Object.entries(properties).map(([key, value]: [string, JSONSchemaDefinition]) => [
key,
{
description: value.description,
typeDescription: (
<DynamicCodeBlock lang="json" code={indent(getTypeDescription(value), 1)} />
),
type: value.type,
default: value.default ? JSON.stringify(value.default) : undefined,
required: required.includes(key),
},
]),
)}
/>
) : null;
}

View File

@@ -24,7 +24,7 @@
"./integration/(runtimes)/",
"---Modules---",
"./modules/overview",
"./modules/server",
"./modules/server/",
"./modules/data",
"./modules/auth",
"./modules/media",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
{
"pages": ["overview", "mcp"]
}

4045
docs/mcp.json Normal file

File diff suppressed because it is too large Load Diff

50
docs/package-lock.json generated
View File

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

View File

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

View File

@@ -0,0 +1,75 @@
/// <reference types="@types/bun" />
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)/modules/server/mcp.mdx",
};
async function generate() {
console.info("Generating MCP documentation...");
try {
console.log("bun version", Bun.version);
} catch (e) {
console.log("bun failed");
}
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<Tool["toJSON"]>[];
resources: ReturnType<Resource["toJSON"]>[];
}) {
return `---
title: "MCP"
description: "Built-in full featured MCP server."
tags: ["documentation"]
---
import { JsonSchemaTypeTable } from '@/components/McpTool';
## Tools
${tools
.map(
(t) => `
### \`${t.name}\`
${t.description ?? ""}
<JsonSchemaTypeTable schema={${JSON.stringify(t.inputSchema)}} key={"${String(t.name)}"} />`,
)
.join("\n")}
## Resources
${resources
.map(
(r) => `
### \`${r.name}\`
${r.description ?? ""}
`,
)
.join("\n")}
`;
}
async function cleanup() {
await rimraf(config.outFile);
}
void generate();

View File

@@ -1,12 +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";
import config from "./config.ts";
export default withPlatformProxy({
d1: {
session: true,
},
});
export default withPlatformProxy(config);

View File

@@ -0,0 +1,7 @@
import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare";
export default {
d1: {
session: true,
},
} satisfies CloudflareBkndConfig;

View File

@@ -1,4 +1,4 @@
import { serve } from "bknd/adapter/cloudflare";
import config from "../bknd.config";
import config from "../config";
export default serve(config);

Some files were not shown because too many files have changed in this diff Show More