mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
10
.cursor/mcp.json
Normal file
10
.cursor/mcp.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"bknd": {
|
||||
"url": "http://localhost:3000/mcp",
|
||||
"headers": {
|
||||
"API_KEY": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
226
app/__test__/app/mcp/mcp.auth.test.ts
Normal file
226
app/__test__/app/mcp/mcp.auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
35
app/__test__/app/mcp/mcp.base.test.ts
Normal file
35
app/__test__/app/mcp/mcp.base.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
346
app/__test__/app/mcp/mcp.data.test.ts
Normal file
346
app/__test__/app/mcp/mcp.data.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
118
app/__test__/app/mcp/mcp.media.test.ts
Normal file
118
app/__test__/app/mcp/mcp.media.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
72
app/__test__/app/mcp/mcp.server.test.ts
Normal file
72
app/__test__/app/mcp/mcp.server.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
56
app/__test__/app/mcp/mcp.system.test.ts
Normal file
56
app/__test__/app/mcp/mcp.system.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
24
app/__test__/debug/jsonv-resolution.test.ts
Normal file
24
app/__test__/debug/jsonv-resolution.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -69,6 +69,8 @@ const external = [
|
||||
"@libsql/client",
|
||||
"bknd",
|
||||
/^bknd\/.*/,
|
||||
"jsonv-ts",
|
||||
/^jsonv-ts\/.*/,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
#registry = "http://localhost:4873"
|
||||
|
||||
[test]
|
||||
coverageSkipTestFiles = true
|
||||
coverageSkipTestFiles = true
|
||||
console.depth = 10
|
||||
35
app/internal/docs.build-assets.ts
Normal file
35
app/internal/docs.build-assets.ts
Normal 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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
84
app/src/cli/commands/mcp/mcp.ts
Normal file
84
app/src/cli/commands/mcp/mcp.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
type ConnQueryResults,
|
||||
customIntrospector,
|
||||
} from "./Connection";
|
||||
export { DummyConnection } from "./DummyConnection";
|
||||
|
||||
// sqlite
|
||||
export { SqliteConnection } from "./sqlite/SqliteConnection";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
137
app/src/modules/mcp/$object.ts
Normal file
137
app/src/modules/mcp/$object.ts
Normal 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;
|
||||
};
|
||||
265
app/src/modules/mcp/$record.ts
Normal file
265
app/src/modules/mcp/$record.ts
Normal 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;
|
||||
88
app/src/modules/mcp/$schema.ts
Normal file
88
app/src/modules/mcp/$schema.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
77
app/src/modules/mcp/McpSchemaHelper.ts
Normal file
77
app/src/modules/mcp/McpSchemaHelper.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
4
app/src/modules/mcp/index.ts
Normal file
4
app/src/modules/mcp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./$object";
|
||||
export * from "./$record";
|
||||
export * from "./$schema";
|
||||
export * from "./McpSchemaHelper";
|
||||
37
app/src/modules/mcp/system-mcp.ts
Normal file
37
app/src/modules/mcp/system-mcp.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
39
app/src/modules/mcp/utils.spec.ts
Normal file
39
app/src/modules/mcp/utils.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
app/src/modules/mcp/utils.ts
Normal file
49
app/src/modules/mcp/utils.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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/*");
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as s from "jsonv-ts";
|
||||
import { s } from "bknd/utils";
|
||||
|
||||
import type {
|
||||
CustomValidator,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
16
app/src/ui/routes/tools/index.tsx
Normal file
16
app/src/ui/routes/tools/index.tsx
Normal 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." />;
|
||||
}
|
||||
15
app/src/ui/routes/tools/mcp/components/mcp-icon.tsx
Normal file
15
app/src/ui/routes/tools/mcp/components/mcp-icon.tsx
Normal 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>
|
||||
);
|
||||
77
app/src/ui/routes/tools/mcp/mcp.tsx
Normal file
77
app/src/ui/routes/tools/mcp/mcp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/src/ui/routes/tools/mcp/state.ts
Normal file
31
app/src/ui/routes/tools/mcp/state.ts
Normal 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 }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
221
app/src/ui/routes/tools/mcp/tools.tsx
Normal file
221
app/src/ui/routes/tools/mcp/tools.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
20
app/src/ui/routes/tools/mcp/utils.ts
Normal file
20
app/src/ui/routes/tools/mcp/utils.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
|
||||
12
bun.lock
12
bun.lock
@@ -35,7 +35,7 @@
|
||||
"hono": "4.8.3",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"jsonv-ts": "0.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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
55
docs/components/McpTool.tsx
Normal file
55
docs/components/McpTool.tsx
Normal 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;
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
"./integration/(runtimes)/",
|
||||
"---Modules---",
|
||||
"./modules/overview",
|
||||
"./modules/server",
|
||||
"./modules/server/",
|
||||
"./modules/data",
|
||||
"./modules/auth",
|
||||
"./modules/media",
|
||||
|
||||
361
docs/content/docs/(documentation)/modules/server/mcp.mdx
Normal file
361
docs/content/docs/(documentation)/modules/server/mcp.mdx
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pages": ["overview", "mcp"]
|
||||
}
|
||||
4045
docs/mcp.json
Normal file
4045
docs/mcp.json
Normal file
File diff suppressed because it is too large
Load Diff
50
docs/package-lock.json
generated
50
docs/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
75
docs/scripts/generate-mcp.ts
Normal file
75
docs/scripts/generate-mcp.ts
Normal 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();
|
||||
@@ -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);
|
||||
|
||||
7
examples/cloudflare-worker/config.ts
Normal file
7
examples/cloudflare-worker/config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare";
|
||||
|
||||
export default {
|
||||
d1: {
|
||||
session: true,
|
||||
},
|
||||
} satisfies CloudflareBkndConfig;
|
||||
@@ -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
Reference in New Issue
Block a user