From ffbb61d58acc83d5c37801a9489f6f4ddf1a726a Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 2 Aug 2025 16:33:05 +0200 Subject: [PATCH 001/199] initialized mcp support --- app/__test__/app/mcp.spec.ts | 42 +++++++ app/package.json | 2 +- app/src/auth/auth-schema.ts | 6 +- app/src/auth/authenticate/Authenticator.ts | 54 ++++---- app/src/cli/commands/index.ts | 1 + app/src/cli/commands/mcp/mcp.ts | 97 +++++++++++++++ app/src/core/utils/index.ts | 16 +-- app/src/data/AppData.ts | 10 +- app/src/data/api/DataController.ts | 31 +++++ app/src/media/media-schema.ts | 4 +- app/src/modules/Controller.ts | 2 + app/src/modules/Module.ts | 2 + app/src/modules/ModuleManager.ts | 8 +- app/src/modules/mcp/$object.ts | 136 +++++++++++++++++++++ app/src/modules/mcp/index.ts | 1 + app/src/modules/mcp/utils.spec.ts | 36 ++++++ app/src/modules/mcp/utils.ts | 51 ++++++++ app/src/modules/server/AppServer.ts | 31 +++-- bun.lock | 6 +- 19 files changed, 468 insertions(+), 68 deletions(-) create mode 100644 app/__test__/app/mcp.spec.ts create mode 100644 app/src/cli/commands/mcp/mcp.ts create mode 100644 app/src/modules/mcp/$object.ts create mode 100644 app/src/modules/mcp/index.ts create mode 100644 app/src/modules/mcp/utils.spec.ts create mode 100644 app/src/modules/mcp/utils.ts diff --git a/app/__test__/app/mcp.spec.ts b/app/__test__/app/mcp.spec.ts new file mode 100644 index 0000000..15c19ea --- /dev/null +++ b/app/__test__/app/mcp.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "bun:test"; +import { makeAppFromEnv } from "cli/commands/run"; +import { createApp } from "core/test/utils"; +import { ObjectToolSchema } from "modules/mcp"; +import { s } from "bknd/utils"; + +describe("mcp", () => { + it("...", async () => { + const app = createApp({ + initialConfig: { + auth: { + enabled: true, + }, + }, + }); + await app.build(); + + const appConfig = app.modules.configs(); + const { version, ...appSchema } = app.getSchema(); + + const schema = s.strictObject(appSchema); + + const nodes = [...schema.walk({ data: appConfig })] + .map((n) => { + const path = n.instancePath.join("."); + if (path.startsWith("auth")) { + console.log("schema", n.instancePath, n.schema.constructor.name); + if (path === "auth.jwt") { + //console.log("jwt", n.schema.IS_MCP); + } + } + return n; + }) + .filter((n) => n.schema instanceof ObjectToolSchema) as s.Node[]; + const tools = nodes.flatMap((n) => n.schema.getTools(n)); + + console.log( + "tools", + tools.map((t) => t.name), + ); + }); +}); diff --git a/app/package.json b/app/package.json index aec8170..33d7f27 100644 --- a/app/package.json +++ b/app/package.json @@ -100,7 +100,7 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.3.2", + "jsonv-ts": "^0.5.1", "kysely-d1": "^0.3.0", "kysely-generic-sqlite": "^1.2.1", "libsql-stateless-easy": "^1.8.0", diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index aedce2d..d346da6 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -1,6 +1,7 @@ import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; import { objectTransform, s } from "bknd/utils"; +import { $object } from "modules/mcp"; export const Strategies = { password: { @@ -36,7 +37,7 @@ export type AppAuthStrategies = s.Static; export type AppAuthOAuthStrategy = s.Static; export type AppAuthCustomOAuthStrategy = s.Static; -const guardConfigSchema = s.object({ +const guardConfigSchema = $object("config_auth_guard", { enabled: s.boolean({ default: false }).optional(), }); export const guardRoleSchema = s.strictObject({ @@ -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" }), diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 46dfc04..e775493 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -9,6 +9,7 @@ import type { ServerEnv } from "modules/Controller"; import { pick } from "lodash-es"; import { InvalidConditionsException } from "auth/errors"; import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils"; +import { $object } from "modules/mcp"; import type { AuthStrategy } from "./strategies/Strategy"; type Input = any; // workaround @@ -41,39 +42,38 @@ export interface UserPool { } const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds -export const cookieConfig = s - .object({ - path: s.string({ default: "/" }), - sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), - secure: s.boolean({ default: true }), - httpOnly: s.boolean({ default: true }), - expires: s.number({ default: defaultCookieExpires }), // seconds - partitioned: s.boolean({ default: false }), - renew: s.boolean({ default: true }), - pathSuccess: s.string({ default: "/" }), - pathLoggedOut: s.string({ default: "/" }), - }) +export const cookieConfig = $object("config_auth_cookie", { + path: s.string({ default: "/" }), + sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), + secure: s.boolean({ default: true }), + httpOnly: s.boolean({ default: true }), + expires: s.number({ default: defaultCookieExpires }), // seconds + partitioned: s.boolean({ default: false }), + renew: s.boolean({ default: true }), + pathSuccess: s.string({ default: "/" }), + pathLoggedOut: s.string({ default: "/" }), +}) .partial() .strict(); // @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 = $object( + "config_auth_jwt", + { + // @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 authenticatorConfig = s.object({ jwt: jwtConfig, cookie: cookieConfig, diff --git a/app/src/cli/commands/index.ts b/app/src/cli/commands/index.ts index 9f63382..8e5b8b9 100644 --- a/app/src/cli/commands/index.ts +++ b/app/src/cli/commands/index.ts @@ -6,3 +6,4 @@ export { user } from "./user"; export { create } from "./create"; export { copyAssets } from "./copy-assets"; export { types } from "./types"; +export { mcp } from "./mcp/mcp"; diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts new file mode 100644 index 0000000..f1df60f --- /dev/null +++ b/app/src/cli/commands/mcp/mcp.ts @@ -0,0 +1,97 @@ +import type { CliCommand } from "cli/types"; +import { makeAppFromEnv } from "../run"; +import { s } from "bknd/utils"; +import { ObjectToolSchema } from "modules/mcp"; +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { mcp as mcpMiddleware, McpServer, Resource } from "jsonv-ts/mcp"; +import type { Module } from "modules/Module"; + +export const mcp: CliCommand = (program) => + program + .command("mcp") + .description("mcp server") + .option("--port ", "port to listen on", "3000") + .option("--path ", "path to listen on", "/mcp") + .action(action); + +async function action(options: { port: string; path: string }) { + const app = await makeAppFromEnv({ + server: "node", + }); + + const appConfig = app.modules.configs(); + const { version, ...appSchema } = app.getSchema(); + + const schema = s.strictObject(appSchema); + + const nodes = [...schema.walk({ data: appConfig })].filter( + (n) => n.schema instanceof ObjectToolSchema, + ) as s.Node[]; + const tools = [...nodes.flatMap((n) => n.schema.getTools(n)), ...app.modules.ctx().mcp.tools]; + const resources = [...app.modules.ctx().mcp.resources]; + + const server = new McpServer( + { + name: "bknd", + version: "0.0.1", + }, + { app, ctx: () => app.modules.ctx() }, + tools, + resources, + ); + server + .resource("system_config", "bknd://system/config", (c) => + c.json(c.context.app.toJSON(), { + title: "System Config", + }), + ) + .resource( + "system_config_module", + "bknd://system/config/{module}", + (c, { module }) => { + const m = c.context.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", (c) => + c.json(c.context.app.getSchema(), { + title: "System Schema", + }), + ) + .resource( + "system_schema_module", + "bknd://system/schema/{module}", + (c, { module }) => { + const m = c.context.app.modules.get(module as any); + return c.json(m.getSchema().toJSON(), { + title: `Schema for ${module}`, + }); + }, + { + list: Object.keys(appSchema), + }, + ); + + const hono = new Hono().use( + mcpMiddleware({ + server, + endpoint: { + path: String(options.path) as any, + }, + }), + ); + + serve({ + fetch: hono.fetch, + port: Number(options.port) || 3000, + }); + console.info(`Server is running on http://localhost:${options.port}${options.path}`); + console.info(`⚙️ Tools:\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`); + console.info(`📚 Resources:\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`); +} diff --git a/app/src/core/utils/index.ts b/app/src/core/utils/index.ts index 36928c5..30321ed 100644 --- a/app/src/core/utils/index.ts +++ b/app/src/core/utils/index.ts @@ -13,18 +13,4 @@ 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"; diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 0b4e464..fbe7514 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -1,5 +1,4 @@ -import { transformObject } from "core/utils"; - +import { transformObject } from "bknd/utils"; import { Module } from "modules/Module"; import { DataController } from "./api/DataController"; import { type AppDataConfig, dataConfigSchema } from "./data-schema"; @@ -49,10 +48,9 @@ export class AppData extends Module { this.ctx.em.addIndex(index); } - this.ctx.server.route( - this.basepath, - new DataController(this.ctx, this.config).getController(), - ); + const dataController = new DataController(this.ctx, this.config); + dataController.registerMcp(); + this.ctx.server.route(this.basepath, dataController.getController()); this.ctx.guard.registerPermissions(Object.values(DataPermissions)); this.setBuilt(); diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index b468a08..952ddb4 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -516,4 +516,35 @@ export class DataController extends Controller { return hono; } + + override registerMcp() { + this.ctx.mcp + .resource( + "data_entities", + "bknd://data/entities", + (c) => c.json(c.context.ctx().em.toJSON().entities), + { + title: "Entities", + description: "Retrieve all entities", + }, + ) + .resource( + "data_relations", + "bknd://data/relations", + (c) => c.json(c.context.ctx().em.toJSON().relations), + { + title: "Relations", + description: "Retrieve all relations", + }, + ) + .resource( + "data_indices", + "bknd://data/indices", + (c) => c.json(c.context.ctx().em.toJSON().indices), + { + title: "Indices", + description: "Retrieve all indices", + }, + ); + } } diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index a287d0a..af18e49 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -1,6 +1,7 @@ import { MediaAdapters } from "media/media-registry"; import { registries } from "modules/registries"; import { s, objectTransform } from "bknd/utils"; +import { $object } 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" }), diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index 51ae026..ac6808c 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -68,4 +68,6 @@ export class Controller { // @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(); } + + registerMcp(): void {} } diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index 126a15e..60d6aab 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -7,6 +7,7 @@ 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 } from "jsonv-ts/mcp"; type PartialRec = { [P in keyof T]?: PartialRec }; @@ -19,6 +20,7 @@ export type ModuleBuildContext = { logger: DebugLogger; flags: (typeof Module)["ctx_flags"]; helper: ModuleHelper; + mcp: McpServer<{ ctx: () => ModuleBuildContext }>; }; export abstract class Module { diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 42d9a94..9d90578 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -21,6 +21,7 @@ import { AppMedia } from "../media/AppMedia"; import type { ServerEnv } from "./Controller"; import { Module, type ModuleBuildContext } from "./Module"; import { ModuleHelper } from "./ModuleHelper"; +import { McpServer, type Resource, type Tool } from "jsonv-ts/mcp"; export type { ModuleBuildContext }; @@ -144,6 +145,7 @@ export class ModuleManager { server!: Hono; emgr!: EventManager; guard!: Guard; + mcp!: ModuleBuildContext["mcp"]; private _version: number = 0; private _built = false; @@ -271,6 +273,9 @@ export class ModuleManager { ? this.em.clear() : new EntityManager([], this.connection, [], [], this.emgr); this.guard = new Guard(); + this.mcp = new McpServer(undefined as any, { + ctx: () => this.ctx(), + }); } const ctx = { @@ -281,6 +286,7 @@ export class ModuleManager { guard: this.guard, flags: Module.ctx_flags, logger: this.logger, + mcp: this.mcp, }; return { @@ -702,7 +708,7 @@ export class ModuleManager { return { version: this.version(), ...schemas, - }; + } as { version: number } & ModuleSchemas; } toJSON(secrets?: boolean): { version: number } & ModuleConfigs { diff --git a/app/src/modules/mcp/$object.ts b/app/src/modules/mcp/$object.ts new file mode 100644 index 0000000..511c39a --- /dev/null +++ b/app/src/modules/mcp/$object.ts @@ -0,0 +1,136 @@ +import * as s from "jsonv-ts"; +import { type Resource, Tool, type ToolAnnotation, type ToolHandlerCtx } from "jsonv-ts/mcp"; +import { excludePropertyTypes, rescursiveClean } from "./utils"; +import { autoFormatString, getPath } from "bknd/utils"; +import type { App } from "App"; +import type { ModuleBuildContext } from "modules"; + +export interface McpToolOptions { + title?: string; + description?: string; + annotations?: ToolAnnotation; + tools?: Tool[]; + resources?: Resource[]; +} + +export interface ObjectToolSchemaOptions extends s.IObjectOptions { + mcp?: McpToolOptions; +} + +type AppToolContext = { + app: App; + ctx: () => ModuleBuildContext; +}; +type AppToolHandlerCtx = ToolHandlerCtx; + +export class ObjectToolSchema< + const P extends s.TProperties = s.TProperties, + const O extends s.IObjectOptions = s.IObjectOptions, +> extends s.ObjectSchema { + public readonly mcp: McpToolOptions; + private cleanSchema: s.ObjectSchema; + + constructor( + public name: string, + properties: P, + options?: ObjectToolSchemaOptions, + ) { + const { mcp, ...rest } = options || {}; + + super(properties, rest as any); + this.name = name; + this.mcp = mcp || {}; + this.cleanSchema = this.getCleanSchema(); + } + + private getMcpOptions(action: "get" | "update") { + const { tools, resources, ...rest } = this.mcp; + const label = (text?: string) => + text && [autoFormatString(action), text].filter(Boolean).join(" "); + return { + title: label(this.title ?? this.mcp.title), + description: label(this.description ?? this.mcp.description), + annotations: { + destructiveHint: true, + idempotentHint: true, + ...rest.annotations, + }, + }; + } + + private getCleanSchema() { + const props = excludePropertyTypes(this as any, [ObjectToolSchema]); + const schema = s.strictObject(props); + return rescursiveClean(schema, { + removeRequired: true, + removeDefault: false, + }) as s.ObjectSchema; + } + + private toolGet(node: s.Node) { + return new Tool( + [this.name, "get"].join("_"), + { + ...this.getMcpOptions("get"), + inputSchema: s.strictObject({ + path: s + .string({ + pattern: /^[a-zA-Z0-9_.]{0,}$/, + description: "(optional) path to the property to get, e.g. `key.subkey`", + }) + .optional(), + include_secrets: s.boolean({ default: false }).optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.include_secrets); + const config = getPath(configs, node.instancePath); + const value = getPath(config, params.path ?? []); + return ctx.json({ + path: params.path ?? "", + value: value ?? null, + }); + }, + ); + } + + private toolUpdate(node: s.Node) { + const schema = this.cleanSchema; + return new Tool( + [this.name, "update"].join("_"), + { + ...this.getMcpOptions("update"), + inputSchema: s.strictObject({ + full: s.boolean({ default: false }).optional(), + value: s + .strictObject(schema.properties as any) + .partial() as unknown as s.ObjectSchema, + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + return ctx.json(params); + }, + ); + } + + getTools(node: s.Node): Tool[] { + const { tools = [] } = this.mcp; + return [this.toolGet(node), this.toolUpdate(node), ...tools]; + } + + override toJSON(): s.JSONSchemaDefinition { + const { toJSON, "~standard": _, mcp, cleanSchema, name, ...rest } = this; + return JSON.parse(JSON.stringify(rest)); + } +} + +export const $object = < + const P extends s.TProperties = s.TProperties, + const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions, +>( + name: string, + properties: P, + options?: s.StrictOptions, +): ObjectToolSchema & O => { + return new ObjectToolSchema(name, properties, options) as any; +}; diff --git a/app/src/modules/mcp/index.ts b/app/src/modules/mcp/index.ts new file mode 100644 index 0000000..95aed8f --- /dev/null +++ b/app/src/modules/mcp/index.ts @@ -0,0 +1 @@ +export * from "./$object"; diff --git a/app/src/modules/mcp/utils.spec.ts b/app/src/modules/mcp/utils.spec.ts new file mode 100644 index 0000000..8ac11f1 --- /dev/null +++ b/app/src/modules/mcp/utils.spec.ts @@ -0,0 +1,36 @@ +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(), + b: s.number(), + nested: s.strictObject({ + c: s.string().optional(), + d: s.number(), + nested2: s.record(s.string()), + }), + }); + + //console.log(schema.toJSON()); + console.log( + rescursiveClean(schema, { + removeRequired: true, + removeDefault: true, + }).toJSON(), + ); + /* const result = rescursiveOptional(schema); + expect(result.properties.a.optional).toBe(true); */ + }); + + it("should exclude properties", () => { + const schema = s.strictObject({ + a: s.string(), + b: s.number(), + }); + + console.log(excludePropertyTypes(schema, [s.StringSchema])); + }); +}); diff --git a/app/src/modules/mcp/utils.ts b/app/src/modules/mcp/utils.ts new file mode 100644 index 0000000..5c7ff5a --- /dev/null +++ b/app/src/modules/mcp/utils.ts @@ -0,0 +1,51 @@ +import * as s from "jsonv-ts"; +import { isPlainObject, transformObject } from "bknd/utils"; + +export function rescursiveClean( + input: s.Schema, + opts?: { + removeRequired?: boolean; + removeDefault?: boolean; + }, +): s.Schema { + const json = input.toJSON(); + + const removeRequired = (obj: any) => { + if (isPlainObject(obj)) { + if ("required" in obj && opts?.removeRequired) { + obj.required = undefined; + } + + if ("default" in obj && opts?.removeDefault) { + obj.default = undefined; + } + + if ("properties" in obj && isPlainObject(obj.properties)) { + for (const key in obj.properties) { + obj.properties[key] = removeRequired(obj.properties[key]); + } + } + } + + return obj; + }; + + removeRequired(json); + return s.fromSchema(json); +} + +export function excludePropertyTypes( + input: s.ObjectSchema, + props: (new (...args: any[]) => s.Schema)[], +): s.TProperties { + const properties = { ...input.properties }; + + return transformObject(properties, (value, key) => { + for (const prop of props) { + if (value instanceof prop) { + return undefined; + } + } + return value; + }); +} diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 6a2f851..2927c42 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -1,25 +1,32 @@ 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"], - }), - allow_credentials: s.boolean({ default: true }), - }), -}); + }, + { + description: "Server configuration", + }, +); export type AppServerConfig = s.Static; diff --git a/bun.lock b/bun.lock index 1bf0fac..8fb8b89 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "app": { "name": "bknd", - "version": "0.16.0-rc.0", + "version": "0.16.0", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -71,7 +71,7 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.3.2", + "jsonv-ts": "^0.5.1", "kysely-d1": "^0.3.0", "kysely-generic-sqlite": "^1.2.1", "libsql-stateless-easy": "^1.8.0", @@ -2511,7 +2511,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.5.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-9PoYn7rfk67q5aeDQLPcGUHJ1YWS/f4BPPsWY0HOna6hVKDbgRP+kem9lSvmf8IfQCpq363xVmR/g49MEMGOdQ=="], "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=="], From 104885ea5ce4309743dc1d5b2f35e9ceb41f6570 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 2 Aug 2025 16:36:23 +0200 Subject: [PATCH 002/199] fix tests failing because of new module ctx --- app/__test__/app/App.spec.ts | 1 + app/__test__/modules/module-test-suite.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/app/__test__/app/App.spec.ts b/app/__test__/app/App.spec.ts index 361bbef..4c44941 100644 --- a/app/__test__/app/App.spec.ts +++ b/app/__test__/app/App.spec.ts @@ -20,6 +20,7 @@ describe("App", () => { "guard", "flags", "logger", + "mcp", "helper", ]); }, diff --git a/app/__test__/modules/module-test-suite.ts b/app/__test__/modules/module-test-suite.ts index 99dfcf5..620ccde 100644 --- a/app/__test__/modules/module-test-suite.ts +++ b/app/__test__/modules/module-test-suite.ts @@ -8,6 +8,7 @@ import { EntityManager } from "data/entities/EntityManager"; import { Module, type ModuleBuildContext } from "modules/Module"; import { getDummyConnection } from "../helper"; import { ModuleHelper } from "modules/ModuleHelper"; +import { McpServer } from "jsonv-ts/mcp"; export function makeCtx(overrides?: Partial): ModuleBuildContext { const { dummyConnection } = getDummyConnection(); @@ -19,6 +20,7 @@ export function makeCtx(overrides?: Partial): ModuleBuildCon guard: new Guard(), flags: Module.ctx_flags, logger: new DebugLogger(false), + mcp: new McpServer(), ...overrides, }; return { From 5e5f0ef70f8c16830031e8ef2a055c9bdfc11104 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 2 Aug 2025 16:47:24 +0200 Subject: [PATCH 003/199] fixing jsonv-ts imports --- app/__test__/modules/module-test-suite.ts | 2 +- app/src/cli/commands/mcp/mcp.ts | 3 +-- app/src/core/utils/schema/index.ts | 8 ++++++++ app/src/data/server/query.ts | 8 +++----- app/src/modules/Module.ts | 2 +- app/src/modules/ModuleManager.ts | 3 +-- app/src/modules/mcp/$object.ts | 12 +++++++++--- app/src/modules/mcp/utils.ts | 3 +-- app/src/modules/server/SystemController.ts | 2 +- .../components/form/json-schema/JsonvTsValidator.ts | 2 +- 10 files changed, 27 insertions(+), 18 deletions(-) diff --git a/app/__test__/modules/module-test-suite.ts b/app/__test__/modules/module-test-suite.ts index 620ccde..01f597e 100644 --- a/app/__test__/modules/module-test-suite.ts +++ b/app/__test__/modules/module-test-suite.ts @@ -8,7 +8,7 @@ import { EntityManager } from "data/entities/EntityManager"; import { Module, type ModuleBuildContext } from "modules/Module"; import { getDummyConnection } from "../helper"; import { ModuleHelper } from "modules/ModuleHelper"; -import { McpServer } from "jsonv-ts/mcp"; +import { McpServer } from "bknd/utils"; export function makeCtx(overrides?: Partial): ModuleBuildContext { const { dummyConnection } = getDummyConnection(); diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts index f1df60f..bbfc7fa 100644 --- a/app/src/cli/commands/mcp/mcp.ts +++ b/app/src/cli/commands/mcp/mcp.ts @@ -1,10 +1,9 @@ import type { CliCommand } from "cli/types"; import { makeAppFromEnv } from "../run"; -import { s } from "bknd/utils"; +import { s, mcp as mcpMiddleware, McpServer } from "bknd/utils"; import { ObjectToolSchema } from "modules/mcp"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; -import { mcp as mcpMiddleware, McpServer, Resource } from "jsonv-ts/mcp"; import type { Module } from "modules/Module"; export const mcp: CliCommand = (program) => diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index 0382700..19c0e1b 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -2,6 +2,14 @@ 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 { + mcp, + McpServer, + Resource, + Tool, + type ToolAnnotation, + type ToolHandlerCtx, +} from "jsonv-ts/mcp"; export { secret, SecretSchema } from "./secret"; diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts index f8ba0c0..48e3ed2 100644 --- a/app/src/data/server/query.ts +++ b/app/src/data/server/query.ts @@ -1,7 +1,5 @@ -import { s } from "bknd/utils"; +import { s, isObject, $console } from "bknd/utils"; import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder"; -import { isObject, $console } from "core/utils"; -import type { anyOf, CoercionOptions, Schema } from "jsonv-ts"; // ------- // helpers @@ -97,9 +95,9 @@ export type RepoWithSchema = Record< } >; -const withSchema = (self: Schema): Schema<{}, Type, Type> => +const withSchema = (self: s.Schema): s.Schema<{}, Type, Type> => s.anyOf([stringIdentifier, s.array(stringIdentifier), self], { - coerce: function (this: typeof anyOf, _value: unknown, opts: CoercionOptions = {}) { + coerce: function (this: typeof s.anyOf, _value: unknown, opts: s.CoercionOptions = {}) { let value: any = _value; if (typeof value === "string") { diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index 60d6aab..f5d855c 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -7,7 +7,7 @@ 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 } from "jsonv-ts/mcp"; +import type { McpServer } from "bknd/utils"; type PartialRec = { [P in keyof T]?: PartialRec }; diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 9d90578..c2026a0 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,4 +1,4 @@ -import { mark, stripMark, $console, s, objectEach, transformObject } from "bknd/utils"; +import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils"; import { Guard } from "auth/authorize/Guard"; import { env } from "core/env"; import { BkndError } from "core/errors"; @@ -21,7 +21,6 @@ import { AppMedia } from "../media/AppMedia"; import type { ServerEnv } from "./Controller"; import { Module, type ModuleBuildContext } from "./Module"; import { ModuleHelper } from "./ModuleHelper"; -import { McpServer, type Resource, type Tool } from "jsonv-ts/mcp"; export type { ModuleBuildContext }; diff --git a/app/src/modules/mcp/$object.ts b/app/src/modules/mcp/$object.ts index 511c39a..0a45998 100644 --- a/app/src/modules/mcp/$object.ts +++ b/app/src/modules/mcp/$object.ts @@ -1,7 +1,13 @@ -import * as s from "jsonv-ts"; -import { type Resource, Tool, type ToolAnnotation, type ToolHandlerCtx } from "jsonv-ts/mcp"; import { excludePropertyTypes, rescursiveClean } from "./utils"; -import { autoFormatString, getPath } from "bknd/utils"; +import { + type Resource, + Tool, + type ToolAnnotation, + type ToolHandlerCtx, + autoFormatString, + getPath, + s, +} from "bknd/utils"; import type { App } from "App"; import type { ModuleBuildContext } from "modules"; diff --git a/app/src/modules/mcp/utils.ts b/app/src/modules/mcp/utils.ts index 5c7ff5a..c6de816 100644 --- a/app/src/modules/mcp/utils.ts +++ b/app/src/modules/mcp/utils.ts @@ -1,5 +1,4 @@ -import * as s from "jsonv-ts"; -import { isPlainObject, transformObject } from "bknd/utils"; +import { isPlainObject, transformObject, s } from "bknd/utils"; export function rescursiveClean( input: s.Schema, diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 704da55..2f877e5 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -13,10 +13,10 @@ import { s, describeRoute, InvalidSchemaError, + openAPISpecs, } 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, diff --git a/app/src/ui/components/form/json-schema/JsonvTsValidator.ts b/app/src/ui/components/form/json-schema/JsonvTsValidator.ts index ae744fd..2b4340f 100644 --- a/app/src/ui/components/form/json-schema/JsonvTsValidator.ts +++ b/app/src/ui/components/form/json-schema/JsonvTsValidator.ts @@ -1,4 +1,4 @@ -import * as s from "jsonv-ts"; +import { s } from "bknd/utils"; import type { CustomValidator, From 3e2938f77d80a29581d403e87b27a05c446935bb Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 5 Aug 2025 13:20:00 +0200 Subject: [PATCH 004/199] added initial $record --- app/package.json | 2 +- app/src/App.ts | 4 +- app/src/auth/auth-schema.ts | 32 ++-- app/src/cli/commands/mcp/mcp.ts | 55 ++---- app/src/core/utils/objects.ts | 24 +++ app/src/data/data-schema.ts | 11 +- app/src/media/media-schema.ts | 6 +- app/src/modules/mcp/$object.ts | 125 +++++--------- app/src/modules/mcp/$record.ts | 190 +++++++++++++++++++++ app/src/modules/mcp/McpSchemaHelper.ts | 75 ++++++++ app/src/modules/mcp/index.ts | 2 + app/src/modules/mcp/utils.ts | 9 +- app/src/modules/server/SystemController.ts | 43 +++++ 13 files changed, 430 insertions(+), 148 deletions(-) create mode 100644 app/src/modules/mcp/$record.ts create mode 100644 app/src/modules/mcp/McpSchemaHelper.ts diff --git a/app/package.json b/app/package.json index 33d7f27..50ec4ed 100644 --- a/app/package.json +++ b/app/package.json @@ -100,7 +100,7 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.5.1", + "jsonv-ts": "link:jsonv-ts", "kysely-d1": "^0.3.0", "kysely-generic-sqlite": "^1.2.1", "libsql-stateless-easy": "^1.8.0", diff --git a/app/src/App.ts b/app/src/App.ts index 832ed70..bd519cf 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -172,7 +172,9 @@ export class App; export type AppAuthOAuthStrategy = s.Static; export type AppAuthCustomOAuthStrategy = s.Static; -const guardConfigSchema = $object("config_auth_guard", { +const guardConfigSchema = s.object({ enabled: s.boolean({ default: false }).optional(), }); export const guardRoleSchema = s.strictObject({ @@ -55,20 +55,28 @@ export const authConfigSchema = $object( 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(), + config: s.object({}), + }), + ), guard: guardConfigSchema.optional(), - roles: s.record(guardRoleSchema, { default: {} }).optional(), + roles: $record("config_auth_roles", guardRoleSchema, { default: {} }).optional(), }, { title: "Authentication" }, ); diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts index bbfc7fa..2b0305e 100644 --- a/app/src/cli/commands/mcp/mcp.ts +++ b/app/src/cli/commands/mcp/mcp.ts @@ -1,10 +1,10 @@ import type { CliCommand } from "cli/types"; import { makeAppFromEnv } from "../run"; -import { s, mcp as mcpMiddleware, McpServer } from "bknd/utils"; -import { ObjectToolSchema } from "modules/mcp"; +import { s, mcp as mcpMiddleware, McpServer, isObject } from "bknd/utils"; +import type { McpSchema } from "modules/mcp"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; -import type { Module } from "modules/Module"; +import { mcpSchemaSymbol } from "modules/mcp/McpSchemaHelper"; export const mcp: CliCommand = (program) => program @@ -25,8 +25,8 @@ async function action(options: { port: string; path: string }) { const schema = s.strictObject(appSchema); const nodes = [...schema.walk({ data: appConfig })].filter( - (n) => n.schema instanceof ObjectToolSchema, - ) as s.Node[]; + (n) => isObject(n.schema) && mcpSchemaSymbol in n.schema, + ) as s.Node[]; const tools = [...nodes.flatMap((n) => n.schema.getTools(n)), ...app.modules.ctx().mcp.tools]; const resources = [...app.modules.ctx().mcp.resources]; @@ -39,43 +39,6 @@ async function action(options: { port: string; path: string }) { tools, resources, ); - server - .resource("system_config", "bknd://system/config", (c) => - c.json(c.context.app.toJSON(), { - title: "System Config", - }), - ) - .resource( - "system_config_module", - "bknd://system/config/{module}", - (c, { module }) => { - const m = c.context.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", (c) => - c.json(c.context.app.getSchema(), { - title: "System Schema", - }), - ) - .resource( - "system_schema_module", - "bknd://system/schema/{module}", - (c, { module }) => { - const m = c.context.app.modules.get(module as any); - return c.json(m.getSchema().toJSON(), { - title: `Schema for ${module}`, - }); - }, - { - list: Object.keys(appSchema), - }, - ); const hono = new Hono().use( mcpMiddleware({ @@ -91,6 +54,10 @@ async function action(options: { port: string; path: string }) { port: Number(options.port) || 3000, }); console.info(`Server is running on http://localhost:${options.port}${options.path}`); - console.info(`⚙️ Tools:\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`); - console.info(`📚 Resources:\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`); + console.info( + `⚙️ 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")}`, + ); } diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 2bf1e60..83c5797 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -189,6 +189,30 @@ export function objectDepth(object: object): number { return level; } +export function limitObjectDepth(obj: T, maxDepth: number): T { + function _limit(current: any, depth: number): any { + if (isPlainObject(current)) { + if (depth > maxDepth) { + return undefined; + } + const result: any = {}; + for (const key in current) { + if (Object.prototype.hasOwnProperty.call(current, key)) { + result[key] = _limit(current[key], depth + 1); + } + } + return result; + } + if (Array.isArray(current)) { + // Arrays themselves are not limited, but their object elements are + return current.map((item) => _limit(item, depth)); + } + // Primitives are always returned, regardless of depth + return current; + } + return _limit(obj, 1); +} + export function objectCleanEmpty(obj: Obj): Obj { if (!obj) return obj; return Object.entries(obj).reduce((acc, [key, value]) => { diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index 7b5c0d8..1aba102 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -5,6 +5,7 @@ import { RelationClassMap, RelationFieldClassMap } from "data/relations"; import { entityConfigSchema, entityTypes } from "data/entities"; import { primaryFieldTypes } from "./fields"; import { s } from "bknd/utils"; +import { $object, $record } from "modules/mcp"; export const FIELDS = { ...FieldClassMap, @@ -61,12 +62,14 @@ 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: {}, + }).optional(), + indices: $record("config_data_indices", indicesSchema, { default: {} }).optional(), }); export type AppDataConfig = s.Static; diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index af18e49..dc8f5be 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -1,7 +1,7 @@ import { MediaAdapters } from "media/media-registry"; import { registries } from "modules/registries"; import { s, objectTransform } from "bknd/utils"; -import { $object } from "modules/mcp"; +import { $object, $record } from "modules/mcp"; export const ADAPTERS = { ...MediaAdapters, @@ -39,7 +39,9 @@ export function buildMediaSchema() { }, { default: {} }, ), - adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(), + adapter: $record("config_media_adapter", s.anyOf(Object.values(adapterSchemaObject)), { + maxProperties: 1, + }).optional(), }, { default: {}, diff --git a/app/src/modules/mcp/$object.ts b/app/src/modules/mcp/$object.ts index 0a45998..788947a 100644 --- a/app/src/modules/mcp/$object.ts +++ b/app/src/modules/mcp/$object.ts @@ -1,99 +1,71 @@ -import { excludePropertyTypes, rescursiveClean } from "./utils"; +import { Tool, getPath, limitObjectDepth, s } from "bknd/utils"; import { - type Resource, - Tool, - type ToolAnnotation, - type ToolHandlerCtx, - autoFormatString, - getPath, - s, -} from "bknd/utils"; -import type { App } from "App"; -import type { ModuleBuildContext } from "modules"; + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type McpSchema, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; -export interface McpToolOptions { - title?: string; - description?: string; - annotations?: ToolAnnotation; - tools?: Tool[]; - resources?: Resource[]; -} - -export interface ObjectToolSchemaOptions extends s.IObjectOptions { - mcp?: McpToolOptions; -} - -type AppToolContext = { - app: App; - ctx: () => ModuleBuildContext; -}; -type AppToolHandlerCtx = ToolHandlerCtx; +export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {} export class ObjectToolSchema< - const P extends s.TProperties = s.TProperties, - const O extends s.IObjectOptions = s.IObjectOptions, -> extends s.ObjectSchema { - public readonly mcp: McpToolOptions; - private cleanSchema: s.ObjectSchema; - - constructor( - public name: string, - properties: P, - options?: ObjectToolSchemaOptions, - ) { + const P extends s.TProperties = s.TProperties, + const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions, + > + extends s.ObjectSchema + implements McpSchema +{ + constructor(name: string, properties: P, options?: ObjectToolSchemaOptions) { const { mcp, ...rest } = options || {}; super(properties, rest as any); - this.name = name; - this.mcp = mcp || {}; - this.cleanSchema = this.getCleanSchema(); + this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {}); } - private getMcpOptions(action: "get" | "update") { - const { tools, resources, ...rest } = this.mcp; - const label = (text?: string) => - text && [autoFormatString(action), text].filter(Boolean).join(" "); - return { - title: label(this.title ?? this.mcp.title), - description: label(this.description ?? this.mcp.description), - annotations: { - destructiveHint: true, - idempotentHint: true, - ...rest.annotations, - }, - }; - } - - private getCleanSchema() { - const props = excludePropertyTypes(this as any, [ObjectToolSchema]); - const schema = s.strictObject(props); - return rescursiveClean(schema, { - removeRequired: true, - removeDefault: false, - }) as s.ObjectSchema; + get mcp(): McpSchemaHelper { + return this[mcpSchemaSymbol]; } private toolGet(node: s.Node) { return new Tool( - [this.name, "get"].join("_"), + [this.mcp.name, "get"].join("_"), { - ...this.getMcpOptions("get"), + ...this.mcp.getToolOptions("get"), inputSchema: s.strictObject({ path: s .string({ pattern: /^[a-zA-Z0-9_.]{0,}$/, - description: "(optional) path to the property to get, e.g. `key.subkey`", + 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(), - include_secrets: s.boolean({ default: false }).optional(), }), }, async (params, ctx: AppToolHandlerCtx) => { - const configs = ctx.context.app.toJSON(params.include_secrets); + const configs = ctx.context.app.toJSON(params.secrets); const config = getPath(configs, node.instancePath); - const value = getPath(config, params.path ?? []); + 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, }); }, @@ -101,11 +73,11 @@ export class ObjectToolSchema< } private toolUpdate(node: s.Node) { - const schema = this.cleanSchema; + const schema = this.mcp.cleanSchema; return new Tool( - [this.name, "update"].join("_"), + [this.mcp.name, "update"].join("_"), { - ...this.getMcpOptions("update"), + ...this.mcp.getToolOptions("update"), inputSchema: s.strictObject({ full: s.boolean({ default: false }).optional(), value: s @@ -120,14 +92,9 @@ export class ObjectToolSchema< } getTools(node: s.Node): Tool[] { - const { tools = [] } = this.mcp; + const { tools = [] } = this.mcp.options; return [this.toolGet(node), this.toolUpdate(node), ...tools]; } - - override toJSON(): s.JSONSchemaDefinition { - const { toJSON, "~standard": _, mcp, cleanSchema, name, ...rest } = this; - return JSON.parse(JSON.stringify(rest)); - } } export const $object = < diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts new file mode 100644 index 0000000..2e60651 --- /dev/null +++ b/app/src/modules/mcp/$record.ts @@ -0,0 +1,190 @@ +import { getPath, s, Tool } from "bknd/utils"; +import { + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type McpSchema, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; + +export interface RecordToolSchemaOptions extends s.IRecordOptions, SchemaWithMcpOptions {} + +const opts = Symbol.for("bknd-mcp-record-opts"); + +export class RecordToolSchema< + AP extends s.Schema, + O extends RecordToolSchemaOptions = RecordToolSchemaOptions, + > + extends s.RecordSchema + implements McpSchema +{ + constructor(name: string, ap: AP, options?: RecordToolSchemaOptions, new_schema?: s.Schema) { + const { mcp, ...rest } = options || {}; + super(ap, rest as any); + + this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {}); + this[opts] = { + new_schema, + }; + } + + get mcp(): McpSchemaHelper { + return this[mcpSchemaSymbol]; + } + + private toolGet(node: s.Node>) { + return new Tool( + [this.mcp.name, "get"].join("_"), + { + ...this.mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + key: s + .string({ + description: "key to get", + }) + .optional(), + secrets: s + .boolean({ + default: false, + description: "(optional) include secrets in the response config", + }) + .optional(), + schema: s + .boolean({ + default: false, + description: "(optional) include the schema in the response", + }) + .optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.secrets); + const config = getPath(configs, node.instancePath); + + // @todo: add schema to response + + 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, + key: params.key, + value: value ?? null, + }); + } + + return ctx.json({ + secrets: params.secrets ?? false, + key: null, + value: config ?? null, + }); + }, + ); + } + + private toolAdd(node: s.Node>) { + return new Tool( + [this.mcp.name, "add"].join("_"), + { + ...this.mcp.getToolOptions("add"), + inputSchema: s.strictObject({ + key: s.string({ + description: "key to add", + }), + value: this[opts].new_schema ?? this.additionalProperties, + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(); + const config = getPath(configs, node.instancePath); + + if (params.key in config) { + throw new Error(`Key "${params.key}" already exists in config`); + } + + return ctx.json({ + key: params.key, + value: params.value ?? null, + }); + }, + ); + } + + private toolUpdate(node: s.Node>) { + return new Tool( + [this.mcp.name, "update"].join("_"), + { + ...this.mcp.getToolOptions("update"), + inputSchema: s.strictObject({ + key: s.string({ + description: "key to update", + }), + value: this[opts].new_schema ?? s.object({}), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.secrets); + const config = getPath(configs, node.instancePath); + + if (!(params.key in config)) { + throw new Error(`Key "${params.key}" not found in config`); + } + + const value = getPath(config, params.key); + return ctx.json({ + updated: false, + key: params.key, + value: value ?? null, + }); + }, + ); + } + + private toolRemove(node: s.Node>) { + return new Tool( + [this.mcp.name, "remove"].join("_"), + { + ...this.mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + key: s.string({ + description: "key to remove", + }), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(); + const config = getPath(configs, node.instancePath); + + if (!(params.key in config)) { + throw new Error(`Key "${params.key}" not found in config`); + } + + return ctx.json({ + removed: false, + key: params.key, + }); + }, + ); + } + + getTools(node: s.Node>): Tool[] { + const { tools = [] } = this.mcp.options; + + return [ + this.toolGet(node), + this.toolAdd(node), + this.toolUpdate(node), + this.toolRemove(node), + ...tools, + ].filter(Boolean) as Tool[]; + } +} + +export const $record = ( + name: string, + ap: AP, + options?: s.StrictOptions, + new_schema?: s.Schema, +): RecordToolSchema => new RecordToolSchema(name, ap, options, new_schema) as any; diff --git a/app/src/modules/mcp/McpSchemaHelper.ts b/app/src/modules/mcp/McpSchemaHelper.ts new file mode 100644 index 0000000..2dec0d3 --- /dev/null +++ b/app/src/modules/mcp/McpSchemaHelper.ts @@ -0,0 +1,75 @@ +import type { App } from "bknd"; +import { + type Tool, + type ToolAnnotation, + type Resource, + type ToolHandlerCtx, + s, + isPlainObject, + autoFormatString, +} from "bknd/utils"; +import type { ModuleBuildContext } from "modules"; +import { excludePropertyTypes, rescursiveClean } from "./utils"; + +export const mcpSchemaSymbol = Symbol.for("bknd-mcp-schema"); + +export interface McpToolOptions { + title?: string; + description?: string; + annotations?: ToolAnnotation; + tools?: Tool[]; + resources?: Resource[]; +} + +export interface SchemaWithMcpOptions { + mcp?: McpToolOptions; +} + +export type AppToolContext = { + app: App; + ctx: () => ModuleBuildContext; +}; +export type AppToolHandlerCtx = ToolHandlerCtx; + +export interface McpSchema extends s.Schema { + getTools(node: s.Node): Tool[]; +} + +export class McpSchemaHelper { + cleanSchema: s.ObjectSchema; + + constructor( + public schema: s.Schema, + public name: string, + public options: McpToolOptions, + ) { + this.cleanSchema = this.getCleanSchema(); + } + + private getCleanSchema() { + const props = excludePropertyTypes( + this.schema as any, + (i) => isPlainObject(i) && mcpSchemaSymbol in i, + ); + const schema = s.strictObject(props); + return rescursiveClean(schema, { + removeRequired: true, + removeDefault: false, + }) as s.ObjectSchema; + } + + getToolOptions(suffix?: string) { + const { tools, resources, ...rest } = this.options; + const label = (text?: string) => + text && [suffix && autoFormatString(suffix), text].filter(Boolean).join(" "); + return { + title: label(this.options.title ?? this.schema.title), + description: label(this.options.description ?? this.schema.description), + annotations: { + destructiveHint: true, + idempotentHint: true, + ...rest.annotations, + }, + }; + } +} diff --git a/app/src/modules/mcp/index.ts b/app/src/modules/mcp/index.ts index 95aed8f..4cead9a 100644 --- a/app/src/modules/mcp/index.ts +++ b/app/src/modules/mcp/index.ts @@ -1 +1,3 @@ export * from "./$object"; +export * from "./$record"; +export * from "./McpSchemaHelper"; diff --git a/app/src/modules/mcp/utils.ts b/app/src/modules/mcp/utils.ts index c6de816..1307ea3 100644 --- a/app/src/modules/mcp/utils.ts +++ b/app/src/modules/mcp/utils.ts @@ -35,16 +35,15 @@ export function rescursiveClean( export function excludePropertyTypes( input: s.ObjectSchema, - props: (new (...args: any[]) => s.Schema)[], + props: (instance: s.Schema | unknown) => boolean, ): s.TProperties { const properties = { ...input.properties }; return transformObject(properties, (value, key) => { - for (const prop of props) { - if (value instanceof prop) { - return undefined; - } + if (props(value)) { + return undefined; } + return value; }); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 2f877e5..8b02fee 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -26,6 +26,7 @@ import { } from "modules/ModuleManager"; import * as SystemPermissions from "modules/permissions"; import { getVersion } from "core/env"; +import type { Module } from "modules/Module"; export type ConfigUpdate = { success: true; @@ -357,4 +358,46 @@ 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", (c) => + c.json(this.app.toJSON(), { + title: "System Config", + }), + ) + .resource( + "system_config_module", + "bknd://system/config/{module}", + (c, { module }) => { + 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", (c) => + c.json(this.app.getSchema(), { + title: "System Schema", + }), + ) + .resource( + "system_schema_module", + "bknd://system/schema/{module}", + (c, { module }) => { + 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()), + }, + ); + } } From aa0e6f90d949e818e1a9bf801ddbe885c4171531 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 6 Aug 2025 08:19:29 +0200 Subject: [PATCH 005/199] examples: fixing imports due to 0.16 (#226) --- examples/astro/bknd.config.ts | 4 ++-- examples/nextjs/bknd.config.ts | 4 ++-- examples/plasmic/src/server.ts | 23 +++++++++++------------ examples/react-router/bknd.config.ts | 4 ++-- examples/react/src/App.tsx | 5 ++--- examples/waku/bknd.config.ts | 4 ++-- 6 files changed, 21 insertions(+), 23 deletions(-) diff --git a/examples/astro/bknd.config.ts b/examples/astro/bknd.config.ts index 8ddea7e..b7b20bb 100644 --- a/examples/astro/bknd.config.ts +++ b/examples/astro/bknd.config.ts @@ -1,6 +1,6 @@ import type { AstroBkndConfig } from "bknd/adapter/astro"; import { registerLocalMediaAdapter } from "bknd/adapter/node"; -import { boolean, em, entity, text } from "bknd/data"; +import { boolean, em, entity, text } from "bknd"; import { secureRandomString } from "bknd/utils"; // since we're running in node, we can register the local media adapter @@ -16,7 +16,7 @@ const schema = em({ // register your schema to get automatic type completion type Database = (typeof schema)["DB"]; -declare module "bknd/core" { +declare module "bknd" { interface DB extends Database {} } diff --git a/examples/nextjs/bknd.config.ts b/examples/nextjs/bknd.config.ts index 39a12a7..186ef04 100644 --- a/examples/nextjs/bknd.config.ts +++ b/examples/nextjs/bknd.config.ts @@ -1,5 +1,5 @@ import type { NextjsBkndConfig } from "bknd/adapter/nextjs"; -import { boolean, em, entity, text } from "bknd/data"; +import { boolean, em, entity, text } from "bknd"; import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { secureRandomString } from "bknd/utils"; @@ -22,7 +22,7 @@ const schema = em({ // register your schema to get automatic type completion type Database = (typeof schema)["DB"]; -declare module "bknd/core" { +declare module "bknd" { interface DB extends Database {} } diff --git a/examples/plasmic/src/server.ts b/examples/plasmic/src/server.ts index 5ac41cb..e8e931d 100644 --- a/examples/plasmic/src/server.ts +++ b/examples/plasmic/src/server.ts @@ -1,6 +1,5 @@ -import { App } from "bknd"; import { serve } from "bknd/adapter/vite"; -import { boolean, em, entity, text } from "bknd/data"; +import { App, boolean, em, entity, text } from "bknd"; import { secureRandomString } from "bknd/utils"; export default serve({ @@ -8,23 +7,23 @@ export default serve({ data: em({ todos: entity("todos", { title: text(), - done: boolean() - }) + done: boolean(), + }), }).toJSON(), auth: { enabled: true, jwt: { - secret: secureRandomString(64) - } - } + secret: secureRandomString(64), + }, + }, }, options: { seed: async (ctx) => { await ctx.em.mutator("todos" as any).insertMany([ { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false } + { title: "Build something cool", done: false }, ]); - } + }, }, // here we can hook into the app lifecycle events ... beforeBuild: async (app) => { @@ -34,10 +33,10 @@ export default serve({ // ... to create an initial user await app.module.auth.createUser({ email: "ds@bknd.io", - password: "12345678" + password: "12345678", }); }, - "sync" + "sync", ); - } + }, }); diff --git a/examples/react-router/bknd.config.ts b/examples/react-router/bknd.config.ts index 06239b3..8be1756 100644 --- a/examples/react-router/bknd.config.ts +++ b/examples/react-router/bknd.config.ts @@ -1,6 +1,6 @@ import { registerLocalMediaAdapter } from "bknd/adapter/node"; import type { ReactRouterBkndConfig } from "bknd/adapter/react-router"; -import { boolean, em, entity, text } from "bknd/data"; +import { boolean, em, entity, text } from "bknd"; import { secureRandomString } from "bknd/utils"; // since we're running in node, we can register the local media adapter @@ -15,7 +15,7 @@ const schema = em({ // register your schema to get automatic type completion type Database = (typeof schema)["DB"]; -declare module "bknd/core" { +declare module "bknd" { interface DB extends Database {} } diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index f288529..d72cfb5 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,7 +1,6 @@ import { lazy, Suspense, useEffect, useState } from "react"; -import { App } from "bknd"; import { checksum } from "bknd/utils"; -import { boolean, em, entity, text } from "bknd/data"; +import { App, boolean, em, entity, text } from "bknd"; import { SQLocalConnection } from "@bknd/sqlocal"; import { Route, Router, Switch } from "wouter"; import IndexPage from "~/routes/_index"; @@ -68,7 +67,7 @@ const schema = em({ // register your schema to get automatic type completion type Database = (typeof schema)["DB"]; -declare module "bknd/core" { +declare module "bknd" { interface DB extends Database {} } diff --git a/examples/waku/bknd.config.ts b/examples/waku/bknd.config.ts index 28ecf03..3b384cc 100644 --- a/examples/waku/bknd.config.ts +++ b/examples/waku/bknd.config.ts @@ -1,6 +1,6 @@ import { registerLocalMediaAdapter } from "bknd/adapter/node"; import type { BkndConfig } from "bknd/adapter"; -import { boolean, em, entity, text } from "bknd/data"; +import { boolean, em, entity, text } from "bknd"; import { secureRandomString } from "bknd/utils"; // since we're running in node, we can register the local media adapter @@ -15,7 +15,7 @@ const schema = em({ // register your schema to get automatic type completion type Database = (typeof schema)["DB"]; -declare module "bknd/core" { +declare module "bknd" { interface DB extends Database {} } From ad0d2e6ff8e6e175cac14776714672471cb14f7b Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 6 Aug 2025 08:21:17 +0200 Subject: [PATCH 006/199] add jsonv-ts as dependency for type inference, fix media api types (#227) * add jsonv-ts as dependency for type inference, fix media api types * add jsonv-ts as dependency for type inference, fix media api types * add jsonv-ts as dependency for type inference, fix media api types --- app/package.json | 6 +++--- app/src/media/api/MediaApi.ts | 6 +++--- app/src/media/storage/adapters/s3/StorageS3Adapter.ts | 4 ++-- .../flows/components2/nodes/triggers/TriggerNode.tsx | 4 ++-- bun.lock | 6 +++--- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/package.json b/app/package.json index aec8170..8ad527a 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.16.0", + "version": "0.16.1", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -64,7 +64,8 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "kysely": "^0.27.6", + "jsonv-ts": "0.3.2", + "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", @@ -100,7 +101,6 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.3.2", "kysely-d1": "^0.3.0", "kysely-generic-sqlite": "^1.2.1", "libsql-stateless-easy": "^1.8.0", diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 70cf746..db94b03 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -68,7 +68,7 @@ export class MediaApi extends ModuleApi { } protected uploadFile( - body: File | Blob | ReadableStream, + body: File | Blob | ReadableStream | Buffer, opts?: { filename?: string; path?: TInput; @@ -110,7 +110,7 @@ export class MediaApi extends ModuleApi { } async upload( - item: Request | Response | string | File | Blob | ReadableStream, + item: Request | Response | string | File | Blob | ReadableStream | Buffer, opts: { filename?: string; _init?: Omit; @@ -148,7 +148,7 @@ export class MediaApi extends ModuleApi { entity: string, id: PrimaryFieldType, field: string, - item: Request | Response | string | File | ReadableStream, + item: Request | Response | string | File | ReadableStream | Buffer, opts?: { _init?: Omit; fetcher?: typeof fetch; diff --git a/app/src/media/storage/adapters/s3/StorageS3Adapter.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts index 7f3da6e..bb89265 100644 --- a/app/src/media/storage/adapters/s3/StorageS3Adapter.ts +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.ts @@ -183,13 +183,13 @@ export class StorageS3Adapter extends StorageAdapter { method: "GET", headers: pickHeaders2(headers, [ "if-none-match", - "accept-encoding", + //"accept-encoding", (causes 403 on r2) "accept", "if-modified-since", ]), }); - // Response has to be copied, because of middlewares that might set headers + // response has to be copied, because of middlewares that might set headers return new Response(res.body, { status: res.status, statusText: res.statusText, diff --git a/app/src/ui/modules/flows/components2/nodes/triggers/TriggerNode.tsx b/app/src/ui/modules/flows/components2/nodes/triggers/TriggerNode.tsx index 4bce7a7..4edb4a3 100644 --- a/app/src/ui/modules/flows/components2/nodes/triggers/TriggerNode.tsx +++ b/app/src/ui/modules/flows/components2/nodes/triggers/TriggerNode.tsx @@ -94,8 +94,8 @@ export const TriggerNode = (props: NodeProps - {data.type === "manual" && } - {data.type === "http" && ( + {data?.type === "manual" && } + {data?.type === "http" && ( )} diff --git a/bun.lock b/bun.lock index 1bf0fac..11dd60f 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "app": { "name": "bknd", - "version": "0.16.0-rc.0", + "version": "0.16.1-rc.0", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -35,7 +35,8 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "kysely": "^0.27.6", + "jsonv-ts": "0.3.2", + "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", @@ -71,7 +72,6 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.3.2", "kysely-d1": "^0.3.0", "kysely-generic-sqlite": "^1.2.1", "libsql-stateless-easy": "^1.8.0", From 8fa905a8f17559462eddd7ecb15beacc30eb5817 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 6 Aug 2025 08:29:46 +0200 Subject: [PATCH 007/199] docs: adding poster to assets (#228) * docs: adding poster to assets * readme: fix poster image --- README.md | 2 +- docs/public/assets/poster.png | Bin 0 -> 167344 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 docs/public/assets/poster.png diff --git a/README.md b/README.md index ab86304..76b19af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd) -![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png) +![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/public/assets/poster.png)

diff --git a/docs/public/assets/poster.png b/docs/public/assets/poster.png new file mode 100644 index 0000000000000000000000000000000000000000..f43aa2d39249b411aa98c7b93e5ed4b976fbe11e GIT binary patch literal 167344 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 ci>J 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 literal 0 HcmV?d00001 From 143d6a55ff774c6b973dde8dddc7e38b7ff3811f Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 6 Aug 2025 08:31:19 +0200 Subject: [PATCH 008/199] updated lock to 0.16.1 --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 11dd60f..f9b1786 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "app": { "name": "bknd", - "version": "0.16.1-rc.0", + "version": "0.16.1", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", From 44fdfcc8d9bddde000181110ea53f2e0256d1ea2 Mon Sep 17 00:00:00 2001 From: Jie <51285767+DimplesY@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:19:28 +0800 Subject: [PATCH 009/199] docs: Correct the incorrect value of the config attribute in the admin component --- .../docs/(documentation)/integration/(frameworks)/nextjs.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/(documentation)/integration/(frameworks)/nextjs.mdx b/docs/content/docs/(documentation)/integration/(frameworks)/nextjs.mdx index 143e6e9..03d9eaa 100644 --- a/docs/content/docs/(documentation)/integration/(frameworks)/nextjs.mdx +++ b/docs/content/docs/(documentation)/integration/(frameworks)/nextjs.mdx @@ -145,7 +145,7 @@ export default async function AdminPage() { config={{ basepath: "/admin", logo_return_path: "/../", - color_scheme: "system", + theme: "system", }} /> ); From 4f945842a91674d77da5eae511069f5b9eeafe54 Mon Sep 17 00:00:00 2001 From: Jie <51285767+DimplesY@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:52:05 +0800 Subject: [PATCH 010/199] docs: update astro.mdx --- .../docs/(documentation)/integration/(frameworks)/astro.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx b/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx index 12df1f6..064a422 100644 --- a/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx +++ b/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx @@ -173,7 +173,7 @@ export const prerender = false; withProvider={{ user }} config={{ basepath: "/admin", - color_scheme: "dark", + theme: "dark", logo_return_path: "/../" }} client:only From 6bed2e4fcacd8d21d793e0afcf00e0aebcc616b9 Mon Sep 17 00:00:00 2001 From: stormbyte Date: Wed, 6 Aug 2025 16:06:03 +0200 Subject: [PATCH 011/199] Update to node 24 --- docker/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 551d7b7..132289a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Build stage -FROM node:20 as builder +FROM node:24 as builder WORKDIR /app @@ -12,7 +12,7 @@ RUN npm install --omit=dev bknd@${VERSION} RUN mkdir /output && cp -r node_modules/bknd/dist /output/dist # Stage 2: Final minimal image -FROM node:20-alpine +FROM node:24-alpine WORKDIR /app @@ -29,4 +29,4 @@ ENV DEFAULT_ARGS="--db-url file:/data/data.db" COPY --from=builder /output/dist ./dist EXPOSE 1337 -CMD ["pm2-runtime", "dist/cli/index.js run ${ARGS:-${DEFAULT_ARGS}} --no-open"] \ No newline at end of file +CMD ["pm2-runtime", "dist/cli/index.js run ${ARGS:-${DEFAULT_ARGS}} --no-open"] From 1b02feca930160f77736b43b8fe249657aa81766 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 7 Aug 2025 08:36:12 +0200 Subject: [PATCH 012/199] added mcp tools from routes --- app/package.json | 3 +-- app/src/auth/api/AuthController.ts | 14 +++++++++++- app/src/cli/commands/mcp/mcp.ts | 17 +++++++++++--- app/src/core/utils/objects.ts | 14 ++++++++++++ app/src/core/utils/schema/index.ts | 5 ++++- app/src/data/api/DataController.ts | 26 +++++++++++++++++++++- app/src/data/server/query.spec.ts | 3 --- app/src/data/server/query.ts | 6 +++-- app/src/media/media-schema.ts | 4 +--- app/src/modules/Controller.ts | 3 +-- app/src/modules/mcp/utils.spec.ts | 23 ++++++++++--------- app/src/modules/server/SystemController.ts | 19 +++++----------- bun.lock | 4 ++-- 13 files changed, 97 insertions(+), 44 deletions(-) diff --git a/app/package.json b/app/package.json index 8d4bba4..9d8f501 100644 --- a/app/package.json +++ b/app/package.json @@ -64,7 +64,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.5.2-rc.1", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -101,7 +101,6 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.3.2", "kysely-d1": "^0.3.0", "kysely-generic-sqlite": "^1.2.1", "libsql-stateless-easy": "^1.8.0", diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index b039635..ee414d3 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -5,7 +5,15 @@ 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"; export type AuthActionResponse = { success: boolean; @@ -118,6 +126,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 +170,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"); diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts index 2b0305e..e06c8f2 100644 --- a/app/src/cli/commands/mcp/mcp.ts +++ b/app/src/cli/commands/mcp/mcp.ts @@ -1,6 +1,6 @@ import type { CliCommand } from "cli/types"; import { makeAppFromEnv } from "../run"; -import { s, mcp as mcpMiddleware, McpServer, isObject } from "bknd/utils"; +import { s, mcp as mcpMiddleware, McpServer, isObject, getMcpServer } from "bknd/utils"; import type { McpSchema } from "modules/mcp"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; @@ -19,6 +19,10 @@ async function action(options: { port: string; path: string }) { server: "node", }); + //console.log(info(app.server)); + + const middlewareServer = getMcpServer(app.server); + const appConfig = app.modules.configs(); const { version, ...appSchema } = app.getSchema(); @@ -27,8 +31,12 @@ async function action(options: { port: string; path: string }) { const nodes = [...schema.walk({ data: appConfig })].filter( (n) => isObject(n.schema) && mcpSchemaSymbol in n.schema, ) as s.Node[]; - const tools = [...nodes.flatMap((n) => n.schema.getTools(n)), ...app.modules.ctx().mcp.tools]; - const resources = [...app.modules.ctx().mcp.resources]; + const tools = [ + ...middlewareServer.tools, + ...nodes.flatMap((n) => n.schema.getTools(n)), + ...app.modules.ctx().mcp.tools, + ]; + const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources]; const server = new McpServer( { @@ -43,6 +51,9 @@ async function action(options: { port: string; path: string }) { const hono = new Hono().use( mcpMiddleware({ server, + debug: { + explainEndpoint: true, + }, endpoint: { path: String(options.path) as any, }, diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 83c5797..4a5e129 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -26,6 +26,20 @@ export function omitKeys( return result; } +export function pickKeys( + obj: T, + keys_: readonly K[], +): Pick> { + const keys = new Set(keys_); + const result = {} as Pick>; + for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { + if (keys.has(key as K)) { + (result as any)[key] = value; + } + } + return result; +} + export function safelyParseObjectValues(obj: T): T { return Object.entries(obj).reduce((acc, [key, value]) => { try { diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index 19c0e1b..ebf585b 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -1,12 +1,15 @@ 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, type ToolAnnotation, type ToolHandlerCtx, } from "jsonv-ts/mcp"; diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 952ddb4..c905e5a 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -1,7 +1,7 @@ import type { Handler } from "hono/types"; import type { ModuleBuildContext } from "modules"; import { Controller } from "modules/Controller"; -import { jsc, s, describeRoute, schemaToSpec, omitKeys } from "bknd/utils"; +import { jsc, s, describeRoute, schemaToSpec, omitKeys, pickKeys, mcpTool } from "bknd/utils"; import * as SystemPermissions from "modules/permissions"; import type { AppDataConfig } from "../data-schema"; import type { EntityManager, EntityData } from "data/entities"; @@ -62,6 +62,7 @@ export class DataController extends Controller { hono.get( "/sync", permission(DataPermissions.databaseSync), + mcpTool("data_sync"), describeRoute({ summary: "Sync database schema", tags: ["data"], @@ -165,6 +166,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(); @@ -214,6 +216,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 +239,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 +272,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 +307,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 +388,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 +419,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 +447,7 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityUpdate), + mcpTool("data_entity_update_many"), jsc("param", s.object({ entity: entitiesEnum })), jsc( "json", @@ -458,6 +479,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 +502,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 +523,7 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityDelete), + mcpTool("data_entity_delete_many"), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery.properties.where), async (c) => { diff --git a/app/src/data/server/query.spec.ts b/app/src/data/server/query.spec.ts index 3992599..89585a3 100644 --- a/app/src/data/server/query.spec.ts +++ b/app/src/data/server/query.spec.ts @@ -26,9 +26,6 @@ describe("server/query", () => { expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] }); expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] }); expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] }); - - expect(() => parse({ select: "not allowed" })).toThrow(); - expect(() => parse({ select: "id," })).toThrow(); }); test("join", () => { diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts index 48e3ed2..cb4defe 100644 --- a/app/src/data/server/query.ts +++ b/app/src/data/server/query.ts @@ -5,7 +5,7 @@ import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder" // 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( [ @@ -23,7 +23,7 @@ const stringArray = s.anyOf( if (v.includes(",")) { return v.split(","); } - return [v]; + return [v].filter(Boolean); } return []; }, @@ -78,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); }, diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index dc8f5be..98661c9 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -39,9 +39,7 @@ export function buildMediaSchema() { }, { default: {} }, ), - adapter: $record("config_media_adapter", s.anyOf(Object.values(adapterSchemaObject)), { - maxProperties: 1, - }).optional(), + adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(), }, { default: {}, diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index ac6808c..3b8bd1d 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -65,8 +65,7 @@ export class Controller { protected getEntitiesEnum(em: EntityManager): s.StringSchema { const entities = em.entities.map((e) => e.name); - // @todo: current workaround to allow strings (sometimes building is not fast enough to get the entities) - return entities.length > 0 ? s.anyOf([s.string({ enum: entities }), s.string()]) : s.string(); + return entities.length > 0 ? s.string({ enum: entities }) : s.string(); } registerMcp(): void {} diff --git a/app/src/modules/mcp/utils.spec.ts b/app/src/modules/mcp/utils.spec.ts index 8ac11f1..a947ac1 100644 --- a/app/src/modules/mcp/utils.spec.ts +++ b/app/src/modules/mcp/utils.spec.ts @@ -5,7 +5,7 @@ import { s } from "../../core/utils/schema"; describe("rescursiveOptional", () => { it("should make all properties optional", () => { const schema = s.strictObject({ - a: s.string(), + a: s.string({ default: "a" }), b: s.number(), nested: s.strictObject({ c: s.string().optional(), @@ -15,14 +15,16 @@ describe("rescursiveOptional", () => { }); //console.log(schema.toJSON()); - console.log( - rescursiveClean(schema, { - removeRequired: true, - removeDefault: true, - }).toJSON(), - ); - /* const result = rescursiveOptional(schema); - expect(result.properties.a.optional).toBe(true); */ + 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", () => { @@ -31,6 +33,7 @@ describe("rescursiveOptional", () => { b: s.number(), }); - console.log(excludePropertyTypes(schema, [s.StringSchema])); + const result = excludePropertyTypes(schema, (instance) => instance instanceof s.StringSchema); + expect(Object.keys(result).length).toBe(1); }); }); diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 8b02fee..7d15dc6 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -8,12 +8,12 @@ import { getTimezoneOffset, $console, getRuntimeKey, - SecretSchema, jsc, s, describeRoute, InvalidSchemaError, openAPISpecs, + mcpTool, } from "bknd/utils"; import type { Context, Hono } from "hono"; import { Controller } from "modules/Controller"; @@ -78,6 +78,7 @@ export class SystemController extends Controller { summary: "Get the config for a module", tags: ["system"], }), + mcpTool("system_config"), // @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) => { @@ -284,6 +285,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; @@ -299,6 +301,7 @@ export class SystemController extends Controller { hono.get( "/ping", + mcpTool("system_ping"), describeRoute({ summary: "Ping the server", tags: ["system"], @@ -308,6 +311,7 @@ export class SystemController extends Controller { hono.get( "/info", + mcpTool("system_info"), describeRoute({ summary: "Get the server info", tags: ["system"], @@ -329,19 +333,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, - })), - }, }), ); diff --git a/bun.lock b/bun.lock index f9b1786..124121e 100644 --- a/bun.lock +++ b/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.5.2-rc.1", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2511,7 +2511,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.5.2-rc.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-mGD7peeZcrr/GeeIWxYDZTSa2/LHSeP0cWIELR63WI9p+PWJRUuDOQ4pHcESbG/syyEBvuus4Nbljnlrxwi2bQ=="], "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=="], From 42db5f55c7ddc70fddd0774fa0a50b31d5bfd1f3 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 7 Aug 2025 11:33:46 +0200 Subject: [PATCH 013/199] added authentication, $schema, fixed media adapter mcp --- app/package.json | 2 +- app/src/auth/AppAuth.ts | 29 +++++++- app/src/auth/api/AuthController.ts | 109 ++++++++++++++++++++++++++++- app/src/cli/commands/mcp/mcp.ts | 29 ++++++-- app/src/cli/commands/user.ts | 28 ++------ app/src/media/media-schema.ts | 7 +- app/src/modules/mcp/$schema.ts | 72 +++++++++++++++++++ app/src/modules/mcp/index.ts | 1 + bun.lock | 4 +- 9 files changed, 247 insertions(+), 34 deletions(-) create mode 100644 app/src/modules/mcp/$schema.ts diff --git a/app/package.json b/app/package.json index 9d8f501..8c7f6ab 100644 --- a/app/package.json +++ b/app/package.json @@ -64,7 +64,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.5.2-rc.1", + "jsonv-ts": "0.6.0", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 8ee5423..5f91819 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,4 +1,4 @@ -import type { DB } from "bknd"; +import type { DB, PrimaryFieldType } from "bknd"; import * as AuthPermissions from "auth/auth-permissions"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; @@ -87,6 +87,7 @@ export class AppAuth extends Module { super.setBuilt(); this._controller = new AuthController(this); + this._controller.registerMcp(); this.ctx.server.route(this.config.basepath, this._controller.getController()); this.ctx.guard.registerPermissions(AuthPermissions); } @@ -176,6 +177,32 @@ export class AppAuth extends Module { return created; } + async changePassword(userId: PrimaryFieldType, newPassword: string) { + const users_entity = this.config.entity_name as "users"; + const { data: user } = await this.em.repository(users_entity).findId(userId); + if (!user) { + throw new Error("User not found"); + } else if (user.strategy !== "password") { + throw new Error("User is not using password strategy"); + } + + const togglePw = (visible: boolean) => { + const field = this.em.entity(users_entity).field("strategy_value")!; + + field.config.hidden = !visible; + field.config.fillable = visible; + }; + + const pw = this.authenticator.strategy("password" as const) as PasswordStrategy; + togglePw(true); + await this.em.mutator(users_entity).updateOne(user.id, { + strategy_value: await pw.hash(newPassword), + }); + togglePw(false); + + return true; + } + override toJSON(secrets?: boolean): AppAuthSchema { if (!this.config.enabled) { return this.configDefault; diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index ee414d3..f704c01 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,4 +1,4 @@ -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"; @@ -14,6 +14,7 @@ import { transformObject, mcpTool, } from "bknd/utils"; +import type { PasswordStrategy } from "auth/authenticate/strategies"; export type AuthActionResponse = { success: boolean; @@ -200,4 +201,110 @@ 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) => { + 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) => { + 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) => { + 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) => { + const pw = this.auth.authenticator.strategy("password") as PasswordStrategy; + const controller = pw.getController(this.auth.authenticator); + + const res = await controller.request( + new Request("https://localhost/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: params.email, + password: params.password, + }), + }), + ); + + return c.json({ valid: res.ok }); + }, + ); + } } diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts index e06c8f2..87f1a76 100644 --- a/app/src/cli/commands/mcp/mcp.ts +++ b/app/src/cli/commands/mcp/mcp.ts @@ -5,6 +5,7 @@ import type { McpSchema } from "modules/mcp"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { mcpSchemaSymbol } from "modules/mcp/McpSchemaHelper"; +import { getVersion } from "cli/utils/sys"; export const mcp: CliCommand = (program) => program @@ -12,15 +13,24 @@ export const mcp: CliCommand = (program) => .description("mcp server") .option("--port ", "port to listen on", "3000") .option("--path ", "path to listen on", "/mcp") + .option( + "--token ", + "token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable", + ) + .option("--log-level ", "log level") .action(action); -async function action(options: { port: string; path: string }) { +async function action(options: { + port?: string; + path?: string; + token?: string; + logLevel?: string; +}) { const app = await makeAppFromEnv({ server: "node", }); - //console.log(info(app.server)); - + const token = options.token || process.env.BEARER_TOKEN; const middlewareServer = getMcpServer(app.server); const appConfig = app.modules.configs(); @@ -33,25 +43,34 @@ async function action(options: { port: string; path: string }) { ) as s.Node[]; const tools = [ ...middlewareServer.tools, - ...nodes.flatMap((n) => n.schema.getTools(n)), ...app.modules.ctx().mcp.tools, + ...nodes.flatMap((n) => n.schema.getTools(n)), ]; const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources]; const server = new McpServer( { name: "bknd", - version: "0.0.1", + version: await getVersion(), }, { app, ctx: () => app.modules.ctx() }, tools, resources, ); + if (token) { + server.setAuthentication({ + type: "bearer", + token, + }); + } + const hono = new Hono().use( mcpMiddleware({ server, + sessionsEnabled: true, debug: { + logLevel: options.logLevel as any, explainEndpoint: true, }, endpoint: { diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 4f4db7c..811f710 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -85,9 +85,6 @@ async function create(app: App, options: any) { async function update(app: App, options: any) { const config = app.module.auth.toJSON(true); - const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; - const users_entity = config.entity_name as "users"; - const em = app.modules.ctx().em; const email = (await $text({ message: "Which user? Enter email", @@ -100,7 +97,10 @@ async function update(app: App, options: any) { })) as string; if ($isCancel(email)) process.exit(1); - const { data: user } = await em.repository(users_entity).findOne({ email }); + const { data: user } = await app.modules + .ctx() + .em.repository(config.entity_name as "users") + .findOne({ email }); if (!user) { $log.error("User not found"); process.exit(1); @@ -118,26 +118,10 @@ async function update(app: App, options: any) { }); if ($isCancel(password)) process.exit(1); - try { - function togglePw(visible: boolean) { - const field = em.entity(users_entity).field("strategy_value")!; - - field.config.hidden = !visible; - field.config.fillable = visible; - } - togglePw(true); - await app.modules - .ctx() - .em.mutator(users_entity) - .updateOne(user.id, { - strategy_value: await strategy.hash(password as string), - }); - togglePw(false); - + if (await app.module.auth.changePassword(user.id, password)) { $log.success(`Updated user: ${c.cyan(user.email)}`); - } catch (e) { + } else { $log.error("Error updating user"); - $console.error(e); } } diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 98661c9..c506ae2 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -1,7 +1,7 @@ import { MediaAdapters } from "media/media-registry"; import { registries } from "modules/registries"; import { s, objectTransform } from "bknd/utils"; -import { $object, $record } from "modules/mcp"; +import { $object, $record, $schema } from "modules/mcp"; export const ADAPTERS = { ...MediaAdapters, @@ -39,7 +39,10 @@ export function buildMediaSchema() { }, { default: {} }, ), - adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(), + adapter: $schema( + "config_media_adapter", + s.anyOf(Object.values(adapterSchemaObject)), + ).optional(), }, { default: {}, diff --git a/app/src/modules/mcp/$schema.ts b/app/src/modules/mcp/$schema.ts new file mode 100644 index 0000000..bc707ab --- /dev/null +++ b/app/src/modules/mcp/$schema.ts @@ -0,0 +1,72 @@ +import { Tool, getPath, s } from "bknd/utils"; +import { + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; + +export interface SchemaToolSchemaOptions extends s.ISchemaOptions, SchemaWithMcpOptions {} + +export const $schema = < + const S extends s.Schema, + const O extends SchemaToolSchemaOptions = SchemaToolSchemaOptions, +>( + name: string, + schema: S, + options?: O, +): S => { + const mcp = new McpSchemaHelper(schema, name, options || {}); + + const toolGet = (node: s.Node) => { + return new Tool( + [mcp.name, "get"].join("_"), + { + ...mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + secrets: s + .boolean({ + default: false, + description: "Include secrets in the response config", + }) + .optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.secrets); + const value = getPath(configs, node.instancePath); + + return ctx.json({ + secrets: params.secrets ?? false, + value: value ?? null, + }); + }, + ); + }; + + const toolUpdate = (node: s.Node) => { + return new Tool( + [mcp.name, "update"].join("_"), + { + ...mcp.getToolOptions("update"), + inputSchema: s.strictObject({ + full: s.boolean({ default: false }).optional(), + value: schema as any, + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + return ctx.json(params); + }, + ); + }; + + const getTools = (node: s.Node) => { + const { tools = [] } = mcp.options; + return [toolGet(node), toolUpdate(node), ...tools]; + }; + + return Object.assign(schema, { + [mcpSchemaSymbol]: mcp, + getTools, + }); +}; diff --git a/app/src/modules/mcp/index.ts b/app/src/modules/mcp/index.ts index 4cead9a..9a19c09 100644 --- a/app/src/modules/mcp/index.ts +++ b/app/src/modules/mcp/index.ts @@ -1,3 +1,4 @@ export * from "./$object"; export * from "./$record"; +export * from "./$schema"; export * from "./McpSchemaHelper"; diff --git a/bun.lock b/bun.lock index 124121e..670c067 100644 --- a/bun.lock +++ b/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.5.2-rc.1", + "jsonv-ts": "0.6.0", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2511,7 +2511,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.5.2-rc.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-mGD7peeZcrr/GeeIWxYDZTSa2/LHSeP0cWIELR63WI9p+PWJRUuDOQ4pHcESbG/syyEBvuus4Nbljnlrxwi2bQ=="], + "jsonv-ts": ["jsonv-ts@0.6.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-U9u2Gtv5NyYwMvSHPcOwd9Qgg4cUFPepI5vTkwU7Ib01ipEqaRrM/AM+REEbYqiI6LX+7YF+HcMbgCIHkUMhSQ=="], "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=="], From 170ea2c45b687064d3a678b2f6238a8a32a24ee3 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 7 Aug 2025 15:20:29 +0200 Subject: [PATCH 014/199] added additional permissions, implemented mcp authentication --- .cursor/mcp.json | 10 ++++ app/package.json | 2 +- app/src/auth/api/AuthController.ts | 8 ++++ app/src/auth/auth-permissions.ts | 3 ++ app/src/auth/authenticate/Authenticator.ts | 55 +++++++++++++--------- app/src/cli/commands/mcp/mcp.ts | 25 +++++++--- app/src/cli/commands/user.ts | 4 ++ app/src/data/api/DataController.ts | 2 - app/src/data/data-schema.ts | 8 ++-- app/src/data/entities/Entity.ts | 19 ++++---- app/src/modules/Controller.ts | 16 +------ app/src/modules/Module.ts | 7 ++- app/src/modules/ModuleHelper.ts | 22 ++++++++- app/src/modules/ModuleManager.ts | 5 ++ app/src/modules/server/SystemController.ts | 28 +++++++---- bun.lock | 4 +- 16 files changed, 144 insertions(+), 74 deletions(-) create mode 100644 .cursor/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..064ec5c --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "bknd": { + "url": "http://localhost:3000/mcp", + "headers": { + "API_KEY": "value" + } + } + } +} diff --git a/app/package.json b/app/package.json index 8c7f6ab..3ef908d 100644 --- a/app/package.json +++ b/app/package.json @@ -64,7 +64,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.6.0", + "jsonv-ts": "0.6.2", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index f704c01..d54d1e2 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -236,6 +236,8 @@ export class AuthController extends Controller { }), }, async (params, c) => { + await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c); + return c.json(await this.auth.createUser(params)); }, ); @@ -251,6 +253,8 @@ export class AuthController extends Controller { }), }, 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) }); }, @@ -268,6 +272,8 @@ export class AuthController extends Controller { }), }, 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"); @@ -287,6 +293,8 @@ export class AuthController extends Controller { }), }, 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); diff --git a/app/src/auth/auth-permissions.ts b/app/src/auth/auth-permissions.ts index ed57c50..8b097e7 100644 --- a/app/src/auth/auth-permissions.ts +++ b/app/src/auth/auth-permissions.ts @@ -2,3 +2,6 @@ import { Permission } from "core/security/Permission"; export const createUser = new Permission("auth.user.create"); //export const updateUser = new Permission("auth.user.update"); +export const testPassword = new Permission("auth.user.password.test"); +export const changePassword = new Permission("auth.user.password.change"); +export const createToken = new Permission("auth.user.token.create"); diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index e775493..6c24c93 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -42,27 +42,25 @@ export interface UserPool { } const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds -export const cookieConfig = $object("config_auth_cookie", { - path: s.string({ default: "/" }), - sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), - secure: s.boolean({ default: true }), - httpOnly: s.boolean({ default: true }), - expires: s.number({ default: defaultCookieExpires }), // seconds - partitioned: s.boolean({ default: false }), - renew: s.boolean({ default: true }), - pathSuccess: s.string({ default: "/" }), - pathLoggedOut: s.string({ default: "/" }), -}) - .partial() - .strict(); +export const cookieConfig = s + .strictObject({ + path: s.string({ default: "/" }), + sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), + secure: s.boolean({ default: true }), + httpOnly: s.boolean({ default: true }), + expires: s.number({ default: defaultCookieExpires }), // seconds + partitioned: s.boolean({ default: false }), + renew: s.boolean({ default: true }), + pathSuccess: s.string({ default: "/" }), + pathLoggedOut: s.string({ default: "/" }), + }) + .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 = $object( - "config_auth_jwt", +export const jwtConfig = s.strictObject( { - // @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 @@ -72,7 +70,7 @@ export const jwtConfig = $object( { default: {}, }, -).strict(); +); export const authenticatorConfig = s.object({ jwt: jwtConfig, @@ -378,13 +376,24 @@ export class Authenticator< } // @todo: don't extract user from token, but from the database or cache - async resolveAuthFromRequest(c: Context): Promise { - let token: string | undefined; - if (c.req.raw.headers.has("Authorization")) { - const bearerHeader = String(c.req.header("Authorization")); - token = bearerHeader.replace("Bearer ", ""); + async resolveAuthFromRequest(c: Context | Request | Headers): Promise { + let headers: Headers; + let is_context = false; + if (c instanceof Headers) { + headers = c; + } else if (c instanceof Request) { + headers = c.headers; } else { - token = await this.getAuthCookie(c); + is_context = true; + headers = c.req.raw.headers; + } + + let token: string | undefined; + if (headers.has("Authorization")) { + const bearerHeader = String(headers.get("Authorization")); + token = bearerHeader.replace("Bearer ", ""); + } else if (is_context) { + token = await this.getAuthCookie(c as Context); } if (token) { diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts index 87f1a76..1b2c424 100644 --- a/app/src/cli/commands/mcp/mcp.ts +++ b/app/src/cli/commands/mcp/mcp.ts @@ -11,6 +11,9 @@ export const mcp: CliCommand = (program) => program .command("mcp") .description("mcp server") + .option("--verbose", "verbose output") + .option("--config ", "config file") + .option("--db-url ", "database url, can be any valid sqlite url") .option("--port ", "port to listen on", "3000") .option("--path ", "path to listen on", "/mcp") .option( @@ -21,12 +24,17 @@ export const mcp: CliCommand = (program) => .action(action); async function action(options: { + verbose?: boolean; + config?: string; + dbUrl?: string; port?: string; path?: string; token?: string; logLevel?: string; }) { const app = await makeAppFromEnv({ + config: options.config, + dbUrl: options.dbUrl, server: "node", }); @@ -83,11 +91,14 @@ async function action(options: { fetch: hono.fetch, port: Number(options.port) || 3000, }); - console.info(`Server is running on http://localhost:${options.port}${options.path}`); - console.info( - `⚙️ 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")}`, - ); + + if (options.verbose) { + console.info(`Server is running on http://localhost:${options.port}${options.path}`); + console.info( + `⚙️ 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")}`, + ); + } } diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 811f710..9920ef9 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -20,11 +20,15 @@ export const user: CliCommand = (program) => { .addArgument( new Argument("", "action to perform").choices(["create", "update", "token"]), ) + .option("--config ", "config file") + .option("--db-url ", "database url, can be any valid sqlite url") .action(action); }; async function action(action: "create" | "update" | "token", options: any) { const app = await makeAppFromEnv({ + config: options.config, + dbUrl: options.dbUrl, server: "node", }); diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index c905e5a..5b04a8f 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -78,9 +78,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, diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index 1aba102..6394ff9 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -29,15 +29,15 @@ export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => { ); }); export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject)); -export const entityFields = s.record(fieldsSchema); +export const entityFields = s.record(fieldsSchema, { default: {} }); export type TAppDataField = s.Static; export type TAppDataEntityFields = s.Static; export const entitiesSchema = s.strictObject({ name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI) - type: s.string({ enum: entityTypes, default: "regular" }), - config: entityConfigSchema, - fields: entityFields, + type: s.string({ enum: entityTypes, default: "regular" }).optional(), + config: entityConfigSchema.optional(), + fields: entityFields.optional(), }); export type TAppDataEntity = s.Static; diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts index 10612b5..fcbe092 100644 --- a/app/src/data/entities/Entity.ts +++ b/app/src/data/entities/Entity.ts @@ -10,14 +10,17 @@ import { // @todo: entity must be migrated to typebox export const entityConfigSchema = s - .strictObject({ - name: s.string(), - name_singular: s.string(), - description: s.string(), - sort_field: s.string({ default: config.data.default_primary_field }), - sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), - primary_format: s.string({ enum: primaryFieldTypes }), - }) + .strictObject( + { + name: s.string(), + name_singular: s.string(), + description: s.string(), + sort_field: s.string({ default: config.data.default_primary_field }), + sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), + primary_format: s.string({ enum: primaryFieldTypes }), + }, + { default: {} }, + ) .partial(); export type EntityConfig = s.Static; diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index 3b8bd1d..db9f3d8 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -1,4 +1,4 @@ -import type { App, SafeUser } from "bknd"; +import type { App, Permission, SafeUser } from "bknd"; import { type Context, type Env, Hono } from "hono"; import * as middlewares from "modules/middlewares"; import type { EntityManager } from "data/entities"; @@ -19,20 +19,6 @@ export interface ServerEnv extends Env { [key: string]: any; } -/* export type ServerEnv = Env & { - Variables: { - app: App; - // to prevent resolving auth multiple times - auth?: { - resolved: boolean; - registered: boolean; - skip: boolean; - user?: SafeUser; - }; - html?: string; - }; -}; */ - export class Controller { protected middlewares = middlewares; diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index f5d855c..f4b610d 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -1,3 +1,4 @@ +import type { App } from "bknd"; import type { EventManager } from "core/events"; import type { Connection } from "data/connection"; import type { EntityManager } from "data/entities"; @@ -11,6 +12,10 @@ import type { McpServer } from "bknd/utils"; type PartialRec = { [P in keyof T]?: PartialRec }; +export type ModuleBuildContextMcpContext = { + app: App; + ctx: () => ModuleBuildContext; +}; export type ModuleBuildContext = { connection: Connection; server: Hono; @@ -20,7 +25,7 @@ export type ModuleBuildContext = { logger: DebugLogger; flags: (typeof Module)["ctx_flags"]; helper: ModuleHelper; - mcp: McpServer<{ ctx: () => ModuleBuildContext }>; + mcp: McpServer; }; export abstract class Module { diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index 5bfd30d..652f076 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -3,8 +3,11 @@ import { Entity } from "data/entities"; import type { EntityIndex, Field } from "data/fields"; import { entityTypes } from "data/entities/Entity"; import { isEqual } from "lodash-es"; -import type { ModuleBuildContext } from "./Module"; +import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module"; import type { EntityRelation } from "data/relations"; +import type { Permission } from "core/security/Permission"; +import { Exception } from "core/errors"; +import { invariant } from "bknd/utils"; export class ModuleHelper { constructor(protected ctx: Omit) {} @@ -110,4 +113,21 @@ export class ModuleHelper { entity.__replaceField(name, newField); } + + async throwUnlessGranted( + permission: Permission | string, + c: { context: ModuleBuildContextMcpContext; request: Request }, + ) { + invariant(c.context.app, "app is not available in mcp context"); + invariant(c.request instanceof Request, "request is not available in mcp context"); + + const user = await c.context.app.module.auth.authenticator.resolveAuthFromRequest(c.request); + + if (!this.ctx.guard.granted(permission, user)) { + throw new Exception( + `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, + 403, + ); + } + } } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index c2026a0..0201639 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -273,6 +273,11 @@ export class ModuleManager { : 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(), }); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 7d15dc6..c863317 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -354,15 +354,19 @@ export class SystemController extends Controller { const { mcp } = this.app.modules.ctx(); const { version, ...appConfig } = this.app.toJSON(); - mcp.resource("system_config", "bknd://system/config", (c) => - c.json(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}", - (c, { 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}`, @@ -372,15 +376,19 @@ export class SystemController extends Controller { list: Object.keys(appConfig), }, ) - .resource("system_schema", "bknd://system/schema", (c) => - c.json(this.app.getSchema(), { + .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}", - (c, { 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}`, diff --git a/bun.lock b/bun.lock index 670c067..2e0d4ed 100644 --- a/bun.lock +++ b/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.6.0", + "jsonv-ts": "0.6.2", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2511,7 +2511,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.6.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-U9u2Gtv5NyYwMvSHPcOwd9Qgg4cUFPepI5vTkwU7Ib01ipEqaRrM/AM+REEbYqiI6LX+7YF+HcMbgCIHkUMhSQ=="], + "jsonv-ts": ["jsonv-ts@0.6.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-v4rIs0h7hoPiadotSNoLCvKyitUtboizbeydQjfBHb1HJG5ADda+BpNeRFGEGtq0m8405UmpEJ9l2Kt9J4SItQ=="], "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=="], From 7af8e0946823fa86b10c23c0dc77a7b741b0a9eb Mon Sep 17 00:00:00 2001 From: Dimples_YJ <2890841438@qq.com> Date: Fri, 8 Aug 2025 13:51:12 +0800 Subject: [PATCH 015/199] fix: add missing @clack/prompts dependency --- app/package.json | 1 + bun.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/app/package.json b/app/package.json index 8ad527a..97aee9a 100644 --- a/app/package.json +++ b/app/package.json @@ -75,6 +75,7 @@ "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", "@bluwy/giget-core": "^0.1.2", + "@clack/prompts": "^0.11.0", "@cloudflare/vitest-pool-workers": "^0.8.38", "@cloudflare/workers-types": "^4.20250606.0", "@dagrejs/dagre": "^1.1.4", diff --git a/bun.lock b/bun.lock index f9b1786..7d6429f 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", "@bluwy/giget-core": "^0.1.2", + "@clack/prompts": "^0.11.0", "@cloudflare/vitest-pool-workers": "^0.8.38", "@cloudflare/workers-types": "^4.20250606.0", "@dagrejs/dagre": "^1.1.4", @@ -503,6 +504,10 @@ "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + "@clack/core": ["@clack/core@0.5.0", "https://registry.npmmirror.com/@clack/core/-/core-0.5.0.tgz", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], + + "@clack/prompts": ["@clack/prompts@0.11.0", "https://registry.npmmirror.com/@clack/prompts/-/prompts-0.11.0.tgz", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.3.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.17", "workerd": "^1.20250508.0" }, "optionalPeers": ["workerd"] }, "sha512-MtUgNl+QkQyhQvv5bbWP+BpBC1N0me4CHHuP2H4ktmOMKdB/6kkz/lo+zqiA4mEazb4y+1cwyNjVrQ2DWeE4mg=="], From cb873381f1b878cd98f53ef7da60b08a7a3d85ec Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 9 Aug 2025 14:14:51 +0200 Subject: [PATCH 016/199] auto generated tools docs, added stdio transport, added additional mcp config and permissions --- app/internal/docs.build-assets.ts | 35 + app/package.json | 3 +- app/src/App.ts | 5 +- app/src/cli/commands/mcp/mcp.ts | 113 +- app/src/cli/utils/sys.ts | 2 +- app/src/core/utils/console.ts | 16 + app/src/data/connection/index.ts | 1 + app/src/index.ts | 1 + app/src/modules/ModuleHelper.ts | 8 +- app/src/modules/mcp/$object.ts | 32 +- app/src/modules/permissions/index.ts | 1 + app/src/modules/server/AppServer.ts | 3 + app/src/modules/server/SystemController.ts | 28 + app/src/modules/server/system-mcp.ts | 36 + app/tsconfig.json | 6 +- docs/app/[[...slug]]/page.tsx | 4 +- docs/components/McpTool.tsx | 55 + docs/content/docs/(documentation)/meta.json | 2 +- .../(documentation)/modules/server/mcp.mdx | 361 ++ .../(documentation)/modules/server/meta.json | 3 + .../{server.mdx => server/overview.mdx} | 0 docs/mcp.json | 3047 +++++++++++++++++ docs/package-lock.json | 26 +- docs/package.json | 4 +- docs/scripts/generate-mcp.ts | 65 + 25 files changed, 3770 insertions(+), 87 deletions(-) create mode 100644 app/internal/docs.build-assets.ts create mode 100644 app/src/modules/server/system-mcp.ts create mode 100644 docs/components/McpTool.tsx create mode 100644 docs/content/docs/(documentation)/modules/server/mcp.mdx create mode 100644 docs/content/docs/(documentation)/modules/server/meta.json rename docs/content/docs/(documentation)/modules/{server.mdx => server/overview.mdx} (100%) create mode 100644 docs/mcp.json create mode 100644 docs/scripts/generate-mcp.ts diff --git a/app/internal/docs.build-assets.ts b/app/internal/docs.build-assets.ts new file mode 100644 index 0000000..127def7 --- /dev/null +++ b/app/internal/docs.build-assets.ts @@ -0,0 +1,35 @@ +import { createApp } from "bknd/adapter/bun"; + +async function generate() { + console.info("Generating MCP documentation..."); + const app = await createApp({ + initialConfig: { + server: { + mcp: { + enabled: true, + }, + }, + auth: { + enabled: true, + }, + media: { + enabled: true, + adapter: { + type: "local", + config: { + path: "./", + }, + }, + }, + }, + }); + await app.build(); + + const res = await app.server.request("/mcp?explain=1"); + const { tools, resources } = await res.json(); + await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2)); + + console.info("MCP documentation generated."); +} + +void generate(); diff --git a/app/package.json b/app/package.json index 3ef908d..3fecc56 100644 --- a/app/package.json +++ b/app/package.json @@ -43,7 +43,8 @@ "test:e2e:adapters": "bun run e2e/adapters.ts", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", - "test:e2e:report": "playwright show-report" + "test:e2e:report": "playwright show-report", + "docs:build-assets": "bun internal/docs.build-assets.ts" }, "license": "FSL-1.1-MIT", "dependencies": { diff --git a/app/src/App.ts b/app/src/App.ts index bd519cf..82cf2ed 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -168,13 +168,12 @@ export class App program .command("mcp") - .description("mcp server") - .option("--verbose", "verbose output") + .description("mcp server stdio transport") .option("--config ", "config file") .option("--db-url ", "database url, can be any valid sqlite url") - .option("--port ", "port to listen on", "3000") - .option("--path ", "path to listen on", "/mcp") .option( "--token ", "token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable", ) + .option("--verbose", "verbose output") .option("--log-level ", "log level") + .option("--force", "force enable mcp") .action(action); async function action(options: { verbose?: boolean; config?: string; dbUrl?: string; - port?: string; - path?: 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", }); - const token = options.token || process.env.BEARER_TOKEN; - const middlewareServer = getMcpServer(app.server); - - const appConfig = app.modules.configs(); - const { version, ...appSchema } = app.getSchema(); - - const schema = s.strictObject(appSchema); - - const nodes = [...schema.walk({ data: appConfig })].filter( - (n) => isObject(n.schema) && mcpSchemaSymbol in n.schema, - ) as s.Node[]; - const tools = [ - ...middlewareServer.tools, - ...app.modules.ctx().mcp.tools, - ...nodes.flatMap((n) => n.schema.getTools(n)), - ]; - const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources]; - - const server = new McpServer( - { - name: "bknd", - version: await getVersion(), - }, - { app, ctx: () => app.modules.ctx() }, - tools, - resources, - ); - - if (token) { - server.setAuthentication({ - type: "bearer", - token, - }); + 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 hono = new Hono().use( - mcpMiddleware({ - server, - sessionsEnabled: true, - debug: { - logLevel: options.logLevel as any, - explainEndpoint: true, - }, - endpoint: { - path: String(options.path) as any, - }, - }), - ); + const token = options.token || process.env.BEARER_TOKEN; + const server = getSystemMcp(app); - serve({ - fetch: hono.fetch, - port: Number(options.port) || 3000, - }); - - if (options.verbose) { - console.info(`Server is running on http://localhost:${options.port}${options.path}`); + if (verbose) { console.info( - `⚙️ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`, + `\n⚙️ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`, ); console.info( `📚 Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`, ); + console.info("\nMCP server is running on STDIO transport"); + } + + if (options.logLevel) { + server.setLogLevel(options.logLevel as any); + } + + const stdout = process.stdout; + const stdin = process.stdin; + const stderr = process.stderr; + + { + using transport = stdioTransport(server, { + stdin, + stdout, + stderr, + raw: new Request("https://localhost", { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }), + }); } } diff --git a/app/src/cli/utils/sys.ts b/app/src/cli/utils/sys.ts index 56ae32e..4882180 100644 --- a/app/src/cli/utils/sys.ts +++ b/app/src/cli/utils/sys.ts @@ -26,7 +26,7 @@ export async function getVersion(_path: string = "") { return JSON.parse(pkg).version ?? "preview"; } } catch (e) { - console.error("Failed to resolve version"); + //console.error("Failed to resolve version"); } return "unknown"; diff --git a/app/src/core/utils/console.ts b/app/src/core/utils/console.ts index b07fa2c..4932ada 100644 --- a/app/src/core/utils/console.ts +++ b/app/src/core/utils/console.ts @@ -76,6 +76,7 @@ declare global { | { level: TConsoleSeverity; id?: string; + enabled?: boolean; } | undefined; } @@ -86,6 +87,7 @@ const defaultLevel = env("cli_log_level", "log") as TConsoleSeverity; // biome-ignore lint/suspicious/noAssignInExpressions: const config = (globalThis.__consoleConfig ??= { level: defaultLevel, + enabled: true, //id: crypto.randomUUID(), // for debugging }); @@ -95,6 +97,14 @@ export const $console = new Proxy(config as any, { switch (prop) { case "original": return console; + case "disable": + return () => { + config.enabled = false; + }; + case "enable": + return () => { + config.enabled = true; + }; case "setLevel": return (l: TConsoleSeverity) => { config.level = l; @@ -105,6 +115,10 @@ export const $console = new Proxy(config as any, { }; } + if (!config.enabled) { + return () => null; + } + const current = keys.indexOf(config.level); const requested = keys.indexOf(prop as string); @@ -118,6 +132,8 @@ export const $console = new Proxy(config as any, { } & { setLevel: (l: TConsoleSeverity) => void; resetLevel: () => void; + disable: () => void; + enable: () => void; }; export function colorizeConsole(con: typeof console) { diff --git a/app/src/data/connection/index.ts b/app/src/data/connection/index.ts index a55d135..969611a 100644 --- a/app/src/data/connection/index.ts +++ b/app/src/data/connection/index.ts @@ -9,6 +9,7 @@ export { type ConnQueryResults, customIntrospector, } from "./Connection"; +export { DummyConnection } from "./DummyConnection"; // sqlite export { SqliteConnection } from "./sqlite/SqliteConnection"; diff --git a/app/src/index.ts b/app/src/index.ts index 3a7b4d1..19e6fd0 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -130,6 +130,7 @@ export { BaseIntrospector, Connection, customIntrospector, + DummyConnection, type FieldSpec, type IndexSpec, type DbFunctions, diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index 652f076..71031cc 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -116,12 +116,14 @@ export class ModuleHelper { async throwUnlessGranted( permission: Permission | string, - c: { context: ModuleBuildContextMcpContext; request: Request }, + c: { context: ModuleBuildContextMcpContext; raw?: unknown }, ) { invariant(c.context.app, "app is not available in mcp context"); - invariant(c.request instanceof Request, "request is not available in mcp context"); + invariant(c.raw instanceof Request, "request is not available in mcp context"); - const user = await c.context.app.module.auth.authenticator.resolveAuthFromRequest(c.request); + const user = await c.context.app.module.auth.authenticator.resolveAuthFromRequest( + c.raw as Request, + ); if (!this.ctx.guard.granted(permission, user)) { throw new Exception( diff --git a/app/src/modules/mcp/$object.ts b/app/src/modules/mcp/$object.ts index 788947a..f52c723 100644 --- a/app/src/modules/mcp/$object.ts +++ b/app/src/modules/mcp/$object.ts @@ -6,6 +6,7 @@ import { type McpSchema, type SchemaWithMcpOptions, } from "./McpSchemaHelper"; +import type { Module } from "modules/Module"; export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {} @@ -80,13 +81,36 @@ export class ObjectToolSchema< ...this.mcp.getToolOptions("update"), inputSchema: s.strictObject({ full: s.boolean({ default: false }).optional(), - value: s - .strictObject(schema.properties as any) - .partial() as unknown as s.ObjectSchema, + 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) => { - return ctx.json(params); + 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, + }); }, ); } diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index cc54754..b6fbead 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -7,3 +7,4 @@ export const configReadSecrets = new Permission("system.config.read.secrets"); export const configWrite = new Permission("system.config.write"); export const schemaRead = new Permission("system.schema.read"); export const build = new Permission("system.build"); +export const mcp = new Permission("system.mcp"); diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 2927c42..31a76c5 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -22,6 +22,9 @@ export const serverConfigSchema = $object( }), allow_credentials: s.boolean({ default: true }), }), + mcp: s.strictObject({ + enabled: s.boolean({ default: false }), + }), }, { description: "Server configuration", diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index c863317..dcc5d14 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -14,6 +14,7 @@ import { InvalidSchemaError, openAPISpecs, mcpTool, + mcp as mcpMiddleware, } from "bknd/utils"; import type { Context, Hono } from "hono"; import { Controller } from "modules/Controller"; @@ -27,6 +28,7 @@ import { import * as SystemPermissions from "modules/permissions"; import { getVersion } from "core/env"; import type { Module } from "modules/Module"; +import { getSystemMcp } from "./system-mcp"; export type ConfigUpdate = { success: true; @@ -52,6 +54,32 @@ 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(); + + const mcpServer = getSystemMcp(app); + + app.server.use( + mcpMiddleware({ + server: mcpServer, + sessionsEnabled: true, + debug: { + logLevel: "debug", + explainEndpoint: true, + }, + endpoint: { + path: "/mcp", + }, + }), + ); + } + private registerConfigController(client: Hono): void { const { permission } = this.middlewares; // don't add auth again, it's already added in getController diff --git a/app/src/modules/server/system-mcp.ts b/app/src/modules/server/system-mcp.ts new file mode 100644 index 0000000..f8308e0 --- /dev/null +++ b/app/src/modules/server/system-mcp.ts @@ -0,0 +1,36 @@ +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 nodes = [...schema.walk({ data: appConfig })].filter( + (n) => isObject(n.schema) && mcpSchemaSymbol in n.schema, + ) as s.Node[]; + const tools = [ + // tools from hono routes + ...middlewareServer.tools, + // tools added from ctx + ...app.modules.ctx().mcp.tools, + // tools from app schema + ...nodes.flatMap((n) => n.schema.getTools(n)), + ]; + const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources]; + + return new McpServer( + { + name: "bknd", + version: getVersion(), + }, + { app, ctx: () => app.modules.ctx() }, + tools, + resources, + ); +} diff --git a/app/tsconfig.json b/app/tsconfig.json index a40d88a..55264d4 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -32,12 +32,8 @@ "*": ["./src/*"], "bknd": ["./src/index.ts"], "bknd/utils": ["./src/core/utils/index.ts"], - "bknd/core": ["./src/core/index.ts"], "bknd/adapter": ["./src/adapter/index.ts"], - "bknd/client": ["./src/ui/client/index.ts"], - "bknd/data": ["./src/data/index.ts"], - "bknd/media": ["./src/media/index.ts"], - "bknd/auth": ["./src/auth/index.ts"] + "bknd/client": ["./src/ui/client/index.ts"] } }, "include": [ diff --git a/docs/app/[[...slug]]/page.tsx b/docs/app/[[...slug]]/page.tsx index 920e47f..42ab098 100644 --- a/docs/app/[[...slug]]/page.tsx +++ b/docs/app/[[...slug]]/page.tsx @@ -13,10 +13,12 @@ export default async function Page(props: { if (!page) notFound(); const MDXContent = page.data.body; + // in case a page exports a custom toc + const toc = (page.data as any).custom_toc ?? page.data.toc; return ( s.toLowerCase().replace(/ /g, "-"); +export const indent = (s: string, indent = 2) => s.replace(/^/gm, " ".repeat(indent)); + +export function McpTool({ tool }: { tool: ReturnType }) { + return ( +

+ ); +} + +export function JsonSchemaTypeTable({ schema }: { schema: JSONSchemaDefinition }) { + const properties = schema.properties ?? {}; + const required = schema.required ?? []; + const getTypeDescription = (value: any) => + JSON.stringify( + { + ...value, + $target: undefined, + }, + null, + 2, + ); + + return Object.keys(properties).length > 0 ? ( + [ + key, + { + description: value.description, + typeDescription: ( + + ), + type: value.type, + default: value.default ? JSON.stringify(value.default) : undefined, + required: required.includes(key), + }, + ]), + )} + /> + ) : null; +} diff --git a/docs/content/docs/(documentation)/meta.json b/docs/content/docs/(documentation)/meta.json index 896c981..a476c95 100644 --- a/docs/content/docs/(documentation)/meta.json +++ b/docs/content/docs/(documentation)/meta.json @@ -24,7 +24,7 @@ "./integration/(runtimes)/", "---Modules---", "./modules/overview", - "./modules/server", + "./modules/server/", "./modules/data", "./modules/auth", "./modules/media", diff --git a/docs/content/docs/(documentation)/modules/server/mcp.mdx b/docs/content/docs/(documentation)/modules/server/mcp.mdx new file mode 100644 index 0000000..437c024 --- /dev/null +++ b/docs/content/docs/(documentation)/modules/server/mcp.mdx @@ -0,0 +1,361 @@ +--- +title: "MCP" +description: "Built-in full featured MCP server." +tags: ["documentation"] +--- +import { JsonSchemaTypeTable } from '@/components/McpTool'; + +## Tools + + +### data_sync + +Sync database schema + + + +### data_entity_fn_count + +Count entities + + + +### data_entity_fn_exists + +Check if entity exists + + + +### data_entity_read_one + +Read one + + + +### data_entity_read_many + +Query entities + + + +### data_entity_insert + +Insert one or many + + + +### data_entity_update_many + +Update many + + + +### data_entity_update_one + +Update one + + + +### data_entity_delete_one + +Delete one + + + +### data_entity_delete_many + +Delete many + + + +### data_entity_info + +Retrieve entity info + + + +### auth_me + +Get the current user + + + +### auth_strategies + +Get the available authentication strategies + + + +### system_config + +Get the config for a module + + + +### system_build + +Build the app + + + +### system_ping + +Ping the server + + + +### system_info + +Get the server info + + + +### auth_user_create + +Create a new user + + + +### auth_user_token + +Get a user token + + + +### auth_user_password_change + +Change a user's password + + + +### auth_user_password_test + +Test a user's password + + + +### config_server_get + +Get Server configuration + + + +### config_server_update + +Update Server configuration + + + +### config_data_get + + + + + +### config_data_update + + + + + +### config_data_entities_get + + + + + +### config_data_entities_add + + + + + +### config_data_entities_update + + + + + +### config_data_entities_remove + + + + + +### config_data_relations_get + + + + + +### config_data_relations_add + + + + + +### config_data_relations_update + + + + + +### config_data_relations_remove + + + + + +### config_data_indices_get + + + + + +### config_data_indices_add + + + + + +### config_data_indices_update + + + + + +### config_data_indices_remove + + + + + +### config_auth_get + + + + + +### config_auth_update + + + + + +### config_auth_strategies_get + + + + + +### config_auth_strategies_add + + + + + +### config_auth_strategies_update + + + + + +### config_auth_strategies_remove + + + + + +### config_auth_roles_get + + + + + +### config_auth_roles_add + + + + + +### config_auth_roles_update + + + + + +### config_auth_roles_remove + + + + + +### config_media_get + + + + + +### config_media_update + + + + + +### config_media_adapter_get + + + + + +### config_media_adapter_update + + + + + + +## Resources + + + +### data_entities + +Retrieve all entities + + + +### data_relations + +Retrieve all relations + + + +### data_indices + +Retrieve all indices + + + +### system_config + + + + + +### system_config_module + + + + + +### system_schema + + + + + +### system_schema_module + + + diff --git a/docs/content/docs/(documentation)/modules/server/meta.json b/docs/content/docs/(documentation)/modules/server/meta.json new file mode 100644 index 0000000..29f47f5 --- /dev/null +++ b/docs/content/docs/(documentation)/modules/server/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["overview", "mcp"] +} diff --git a/docs/content/docs/(documentation)/modules/server.mdx b/docs/content/docs/(documentation)/modules/server/overview.mdx similarity index 100% rename from docs/content/docs/(documentation)/modules/server.mdx rename to docs/content/docs/(documentation)/modules/server/overview.mdx diff --git a/docs/mcp.json b/docs/mcp.json new file mode 100644 index 0000000..0ee9e83 --- /dev/null +++ b/docs/mcp.json @@ -0,0 +1,3047 @@ +{ + "tools": [ + { + "name": "data_sync", + "description": "Sync database schema", + "inputSchema": { + "type": "object", + "properties": { + "force": { + "type": "boolean", + "$target": "query" + }, + "drop": { + "type": "boolean", + "$target": "query" + } + } + } + }, + { + "name": "data_entity_fn_count", + "description": "Count entities", + "inputSchema": { + "type": "object", + "required": [ + "entity" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "json": { + "examples": [ + { + "attribute": { + "$eq": 1 + } + } + ], + "default": {}, + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {} + } + ], + "$target": "json" + } + } + } + }, + { + "name": "data_entity_fn_exists", + "description": "Check if entity exists", + "inputSchema": { + "type": "object", + "required": [ + "entity" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "json": { + "examples": [ + { + "attribute": { + "$eq": 1 + } + } + ], + "default": {}, + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {} + } + ], + "$target": "json" + } + } + } + }, + { + "name": "data_entity_read_one", + "description": "Read one", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "id" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "id": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "$target": "param" + }, + "offset": { + "type": "number", + "default": 0, + "$target": "query" + }, + "sort": { + "type": "string", + "default": "id", + "$target": "query" + }, + "select": { + "type": "array", + "$target": "query", + "items": { + "type": "string" + } + } + } + } + }, + { + "name": "data_entity_read_many", + "description": "Query entities", + "inputSchema": { + "type": "object", + "required": [ + "entity" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "limit": { + "type": "number", + "default": 10, + "$target": "json" + }, + "offset": { + "type": "number", + "default": 0, + "$target": "json" + }, + "sort": { + "type": "string", + "default": "id", + "$target": "json" + }, + "where": { + "examples": [ + { + "attribute": { + "$eq": 1 + } + } + ], + "default": {}, + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {} + } + ], + "$target": "json" + }, + "select": { + "type": "array", + "$target": "json", + "items": { + "type": "string" + } + }, + "join": { + "type": "array", + "$target": "json", + "items": { + "type": "string" + } + }, + "with": { + "type": "object", + "$target": "json", + "properties": {} + } + } + } + }, + { + "name": "data_entity_insert", + "description": "Insert one or many", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "json" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "json": { + "anyOf": [ + { + "type": "object", + "properties": {} + }, + { + "type": "array", + "items": { + "type": "object", + "properties": {} + } + } + ], + "$target": "json" + } + } + } + }, + { + "name": "data_entity_update_many", + "description": "Update many", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "update" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "update": { + "type": "object", + "$target": "json", + "properties": {} + }, + "where": { + "examples": [ + { + "attribute": { + "$eq": 1 + } + } + ], + "default": {}, + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {} + } + ], + "$target": "json" + } + } + } + }, + { + "name": "data_entity_update_one", + "description": "Update one", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "id" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "id": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "$target": "param" + } + } + } + }, + { + "name": "data_entity_delete_one", + "description": "Delete one", + "inputSchema": { + "type": "object", + "required": [ + "entity", + "id" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "id": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "$target": "param" + } + } + } + }, + { + "name": "data_entity_delete_many", + "description": "Delete many", + "inputSchema": { + "type": "object", + "required": [ + "entity" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + }, + "json": { + "examples": [ + { + "attribute": { + "$eq": 1 + } + } + ], + "default": {}, + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": {} + } + ], + "$target": "json" + } + } + } + }, + { + "name": "data_entity_info", + "description": "Retrieve entity info", + "inputSchema": { + "type": "object", + "required": [ + "entity" + ], + "properties": { + "entity": { + "type": "string", + "enum": [ + "users", + "media" + ], + "$target": "param" + } + } + } + }, + { + "name": "auth_me", + "description": "Get the current user", + "inputSchema": { + "type": "object" + } + }, + { + "name": "auth_strategies", + "description": "Get the available authentication strategies", + "inputSchema": { + "type": "object", + "properties": { + "include_disabled": { + "type": "boolean", + "$target": "query" + } + } + } + }, + { + "name": "system_config", + "description": "Get the config for a module", + "inputSchema": { + "type": "object", + "properties": { + "module": { + "type": "string", + "enum": [ + "server", + "data", + "auth", + "media", + "flows" + ], + "$target": "param" + }, + "secrets": { + "type": "boolean", + "$target": "query" + } + } + } + }, + { + "name": "system_build", + "description": "Build the app", + "inputSchema": { + "type": "object", + "properties": { + "sync": { + "type": "boolean", + "$target": "query" + }, + "fetch": { + "type": "boolean", + "$target": "query" + } + } + } + }, + { + "name": "system_ping", + "description": "Ping the server", + "inputSchema": { + "type": "object" + } + }, + { + "name": "system_info", + "description": "Get the server info", + "inputSchema": { + "type": "object" + } + }, + { + "name": "auth_user_create", + "description": "Create a new user", + "inputSchema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8 + }, + "role": { + "type": "string", + "enum": [] + } + }, + "required": [ + "email", + "password" + ] + } + }, + { + "name": "auth_user_token", + "description": "Get a user token", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "email": { + "type": "string", + "format": "email" + } + } + } + }, + { + "name": "auth_user_password_change", + "description": "Change a user's password", + "inputSchema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8 + } + }, + "required": [ + "password" + ] + } + }, + { + "name": "auth_user_password_test", + "description": "Test a user's password", + "inputSchema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "minLength": 8 + } + }, + "required": [ + "email", + "password" + ] + } + }, + { + "name": "config_server_get", + "description": "Get Server configuration", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the property to get, e.g. `key.subkey`", + "pattern": "^[a-zA-Z0-9_.]{0,}$" + }, + "depth": { + "type": "number", + "description": "Limit the depth of the response" + }, + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_server_update", + "description": "Update Server configuration", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "full": { + "type": "boolean", + "default": false + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "cors": { + "type": "object", + "additionalProperties": false, + "properties": { + "origin": { + "type": "string", + "default": "*" + }, + "allow_methods": { + "type": "array", + "default": [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE" + ], + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE" + ] + } + }, + "allow_headers": { + "type": "array", + "default": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ], + "items": { + "type": "string" + } + }, + "allow_credentials": { + "type": "boolean", + "default": true + } + } + }, + "mcp": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + } + } + } + } + } + }, + "required": [ + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the property to get, e.g. `key.subkey`", + "pattern": "^[a-zA-Z0-9_.]{0,}$" + }, + "depth": { + "type": "number", + "description": "Limit the depth of the response" + }, + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "full": { + "type": "boolean", + "default": false + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "basepath": { + "type": "string", + "default": "/api/data" + }, + "default_primary_format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ], + "default": "integer" + } + } + } + }, + "required": [ + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_entities_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_entities_add", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "regular", + "system", + "generated" + ], + "default": "regular" + }, + "config": { + "type": "object", + "default": {}, + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "name_singular": { + "type": "string" + }, + "description": { + "type": "string" + }, + "sort_field": { + "type": "string", + "default": "id" + }, + "sort_dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "asc" + }, + "primary_format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ] + } + } + }, + "fields": { + "type": "object", + "default": {}, + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "title": "primary", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "primary" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ], + "default": "integer" + }, + "required": { + "type": "boolean", + "default": false + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "text", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "text" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "string" + }, + "minLength": { + "type": "number" + }, + "maxLength": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "html_config": { + "type": "object", + "properties": { + "element": { + "type": "string" + }, + "props": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "title": "String" + }, + { + "type": "number", + "title": "Number" + } + ] + } + } + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "number", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "number" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "multipleOf": { + "type": "number" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "boolean", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "boolean" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "date", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "date" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "datetime", + "week" + ], + "default": "date" + }, + "timezone": { + "type": "string" + }, + "min_date": { + "type": "string" + }, + "max_date": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "enum", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "enum" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "string" + }, + "options": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "const": "strings" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "values" + ] + }, + { + "type": "object", + "properties": { + "type": { + "const": "objects" + }, + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "label", + "value" + ] + } + } + }, + "required": [ + "type", + "values" + ] + } + ] + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "json", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "json" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": {}, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "jsonschema", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "jsonschema" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "schema": { + "type": "object" + }, + "ui_schema": { + "type": "object" + }, + "default_from_schema": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "relation", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "relation" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "reference": { + "type": "string" + }, + "target": { + "type": "string" + }, + "target_field": { + "type": "string", + "default": "id" + }, + "target_field_type": { + "type": "string", + "enum": [ + "text", + "integer" + ], + "default": "integer" + }, + "on_delete": { + "type": "string", + "enum": [ + "cascade", + "set null", + "set default", + "restrict", + "no action" + ], + "default": "set null" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + }, + "required": [ + "reference", + "target" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "title": "media", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "media" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "entity": { + "type": "string" + }, + "min_items": { + "type": "number" + }, + "max_items": { + "type": "number" + }, + "mime_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + }, + "required": [ + "type" + ] + } + ] + } + } + } + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_entities_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to update" + }, + "value": { + "type": "object", + "properties": {} + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_entities_remove", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_relations_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_relations_add", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "anyOf": [ + { + "type": "object", + "title": "1:1", + "additionalProperties": false, + "properties": { + "type": { + "const": "1:1" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "sourceCardinality": { + "type": "number" + }, + "with_limit": { + "type": "number", + "default": 5 + }, + "fieldConfig": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "required": [ + "label" + ] + }, + "mappedBy": { + "type": "string" + }, + "inversedBy": { + "type": "string" + }, + "required": { + "type": "boolean" + } + } + } + }, + "required": [ + "type", + "source", + "target" + ] + }, + { + "type": "object", + "title": "n:1", + "additionalProperties": false, + "properties": { + "type": { + "const": "n:1" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "sourceCardinality": { + "type": "number" + }, + "with_limit": { + "type": "number", + "default": 5 + }, + "fieldConfig": { + "type": "object", + "properties": { + "label": { + "type": "string" + } + }, + "required": [ + "label" + ] + }, + "mappedBy": { + "type": "string" + }, + "inversedBy": { + "type": "string" + }, + "required": { + "type": "boolean" + } + } + } + }, + "required": [ + "type", + "source", + "target" + ] + }, + { + "type": "object", + "title": "m:n", + "additionalProperties": false, + "properties": { + "type": { + "const": "m:n" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "connectionTable": { + "type": "string" + }, + "connectionTableMappedName": { + "type": "string" + }, + "mappedBy": { + "type": "string" + }, + "inversedBy": { + "type": "string" + }, + "required": { + "type": "boolean" + } + } + } + }, + "required": [ + "type", + "source", + "target" + ] + }, + { + "type": "object", + "title": "poly", + "additionalProperties": false, + "properties": { + "type": { + "const": "poly" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "targetCardinality": { + "type": "number" + }, + "mappedBy": { + "type": "string" + }, + "inversedBy": { + "type": "string" + }, + "required": { + "type": "boolean" + } + } + } + }, + "required": [ + "type", + "source", + "target" + ] + } + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_relations_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to update" + }, + "value": { + "type": "object", + "properties": {} + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_relations_remove", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_indices_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_indices_add", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "entity": { + "type": "string" + }, + "fields": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "unique": { + "type": "boolean", + "default": false + } + }, + "required": [ + "entity", + "fields" + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_indices_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to update" + }, + "value": { + "type": "object", + "properties": {} + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_data_indices_remove", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_get", + "title": "Get Authentication", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the property to get, e.g. `key.subkey`", + "pattern": "^[a-zA-Z0-9_.]{0,}$" + }, + "depth": { + "type": "number", + "description": "Limit the depth of the response" + }, + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_update", + "title": "Update Authentication", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "full": { + "type": "boolean", + "default": false + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "basepath": { + "type": "string", + "default": "/api/auth" + }, + "entity_name": { + "type": "string", + "default": "users" + }, + "allow_register": { + "type": "boolean", + "default": true + }, + "jwt": { + "type": "object", + "default": {}, + "additionalProperties": false, + "properties": { + "secret": { + "type": "string", + "default": "" + }, + "alg": { + "type": "string", + "enum": [ + "HS256", + "HS384", + "HS512" + ], + "default": "HS256" + }, + "expires": { + "type": "number" + }, + "issuer": { + "type": "string" + }, + "fields": { + "type": "array", + "default": [ + "id", + "email", + "role" + ], + "items": { + "type": "string" + } + } + } + }, + "cookie": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "default": "/" + }, + "sameSite": { + "type": "string", + "enum": [ + "strict", + "lax", + "none" + ], + "default": "lax" + }, + "secure": { + "type": "boolean", + "default": true + }, + "httpOnly": { + "type": "boolean", + "default": true + }, + "expires": { + "type": "number", + "default": 604800 + }, + "partitioned": { + "type": "boolean", + "default": false + }, + "renew": { + "type": "boolean", + "default": true + }, + "pathSuccess": { + "type": "string", + "default": "/" + }, + "pathLoggedOut": { + "type": "string", + "default": "/" + } + } + }, + "guard": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + } + } + } + } + } + }, + "required": [ + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_strategies_get", + "title": "Get Strategies", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_strategies_add", + "title": "Add Strategies", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string" + }, + "config": { + "type": "object", + "properties": {} + } + }, + "required": [ + "type", + "config" + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_strategies_update", + "title": "Update Strategies", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to update" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string" + }, + "config": { + "type": "object", + "properties": {} + } + }, + "required": [ + "type", + "config" + ] + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_strategies_remove", + "title": "Get Strategies", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_roles_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to get" + }, + "secrets": { + "type": "boolean", + "description": "(optional) include secrets in the response config", + "default": false + }, + "schema": { + "type": "boolean", + "description": "(optional) include the schema in the response", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_roles_add", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to add" + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "is_default": { + "type": "boolean" + }, + "implicit_allow": { + "type": "boolean" + } + } + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_roles_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to update" + }, + "value": { + "type": "object", + "properties": {} + } + }, + "required": [ + "key", + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_auth_roles_remove", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "key": { + "type": "string", + "description": "key to remove" + } + }, + "required": [ + "key" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_media_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the property to get, e.g. `key.subkey`", + "pattern": "^[a-zA-Z0-9_.]{0,}$" + }, + "depth": { + "type": "number", + "description": "Limit the depth of the response" + }, + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_media_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "full": { + "type": "boolean", + "default": false + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false + }, + "value": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "basepath": { + "type": "string", + "default": "/api/media" + }, + "entity_name": { + "type": "string", + "default": "media" + }, + "storage": { + "type": "object", + "default": {}, + "additionalProperties": false, + "properties": { + "body_max_size": { + "type": "number", + "description": "Max size of the body in bytes. Leave blank for unlimited." + } + } + } + } + } + }, + "required": [ + "value" + ] + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_media_adapter_get", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "secrets": { + "type": "boolean", + "description": "Include secrets in the response config", + "default": false + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + }, + { + "name": "config_media_adapter_update", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "full": { + "type": "boolean", + "default": false + }, + "value": { + "anyOf": [ + { + "type": "object", + "title": "AWS S3", + "description": "AWS S3 or compatible storage", + "additionalProperties": false, + "properties": { + "type": { + "const": "s3" + }, + "config": { + "type": "object", + "title": "AWS S3", + "description": "AWS S3 or compatible storage", + "properties": { + "access_key": { + "type": "string" + }, + "secret_access_key": { + "type": "string" + }, + "url": { + "type": "string", + "description": "URL to S3 compatible endpoint without trailing slash", + "examples": [ + "https://{account_id}.r2.cloudflarestorage.com/{bucket}", + "https://{bucket}.s3.{region}.amazonaws.com" + ], + "pattern": "^https?://(?:.*)?[^/.]+$" + } + }, + "required": [ + "access_key", + "secret_access_key", + "url" + ] + } + }, + "required": [ + "type", + "config" + ] + }, + { + "type": "object", + "title": "Cloudinary", + "description": "Cloudinary media storage", + "additionalProperties": false, + "properties": { + "type": { + "const": "cloudinary" + }, + "config": { + "type": "object", + "title": "Cloudinary", + "description": "Cloudinary media storage", + "properties": { + "cloud_name": { + "type": "string" + }, + "api_key": { + "type": "string" + }, + "api_secret": { + "type": "string" + }, + "upload_preset": { + "type": "string" + } + }, + "required": [ + "cloud_name", + "api_key", + "api_secret" + ] + } + }, + "required": [ + "type", + "config" + ] + }, + { + "type": "object", + "title": "Local", + "description": "Local file system storage", + "additionalProperties": false, + "properties": { + "type": { + "const": "local" + }, + "config": { + "type": "object", + "title": "Local", + "description": "Local file system storage", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "default": "./" + } + }, + "required": [ + "path" + ] + } + }, + "required": [ + "type", + "config" + ] + } + ] + } + } + }, + "annotations": { + "destructiveHint": true, + "idempotentHint": true + } + } + ], + "resources": [ + { + "uri": "bknd://data/entities", + "name": "data_entities", + "title": "Entities", + "description": "Retrieve all entities" + }, + { + "uri": "bknd://data/relations", + "name": "data_relations", + "title": "Relations", + "description": "Retrieve all relations" + }, + { + "uri": "bknd://data/indices", + "name": "data_indices", + "title": "Indices", + "description": "Retrieve all indices" + }, + { + "uri": "bknd://system/config", + "name": "system_config" + }, + { + "uriTemplate": "bknd://system/config/{module}", + "name": "system_config_module" + }, + { + "uri": "bknd://system/schema", + "name": "system_schema" + }, + { + "uriTemplate": "bknd://system/schema/{module}", + "name": "system_schema_module" + } + ] +} \ No newline at end of file diff --git a/docs/package-lock.json b/docs/package-lock.json index 9db3d56..4d64549 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -6,7 +6,6 @@ "packages": { "": { "name": "bknd-docs", - "version": "0.0.0", "hasInstallScript": true, "dependencies": { "@iconify/react": "^6.0.0", @@ -36,6 +35,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", @@ -6816,6 +6816,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 +7536,19 @@ "node": ">=0.10.0" } }, + "node_modules/jsonv-ts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/jsonv-ts/-/jsonv-ts-0.7.0.tgz", + "integrity": "sha512-zN5/KMs1WOs+0IbYiZF7mVku4dum8LKP9xv8VqgVm+PBz5VZuU1V8iLQhI991ogUbhGHHlOCwqxnxQUuvCPbQA==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "hono": "*" + }, + "peerDependencies": { + "typescript": "^5.0.0" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", diff --git a/docs/package.json b/docs/package.json index 486c83c..02d15bf 100644 --- a/docs/package.json +++ b/docs/package.json @@ -4,9 +4,10 @@ "scripts": { "dev": "next dev", "dev:turbo": "next dev --turbo", - "build": "bun generate:openapi && next build", + "build": "bun generate:openapi && bun generate:mcp && next build", "start": "next start", "generate:openapi": "bun scripts/generate-openapi.mjs", + "generate:mcp": "bun scripts/generate-mcp.ts", "postinstall": "fumadocs-mdx", "preview": "npm run build && wrangler dev", "cf:preview": "wrangler dev", @@ -42,6 +43,7 @@ "eslint": "^8", "eslint-config-next": "15.3.5", "fumadocs-docgen": "^2.1.0", + "jsonv-ts": "^0.7.0", "postcss": "^8.5.6", "rimraf": "^6.0.1", "tailwindcss": "^4.1.11", diff --git a/docs/scripts/generate-mcp.ts b/docs/scripts/generate-mcp.ts new file mode 100644 index 0000000..34e798b --- /dev/null +++ b/docs/scripts/generate-mcp.ts @@ -0,0 +1,65 @@ +import type { Tool, Resource } from "jsonv-ts/mcp"; +import { rimraf } from "rimraf"; + +const config = { + mcpConfig: "./mcp.json", + outFile: "./content/docs/(documentation)/modules/server/mcp.mdx", +}; + +async function generate() { + console.info("Generating MCP documentation..."); + await cleanup(); + const mcpConfig = await Bun.file(config.mcpConfig).json(); + const document = await generateDocument(mcpConfig); + await Bun.write(config.outFile, document); + console.info("MCP documentation generated."); +} + +async function generateDocument({ + tools, + resources, +}: { + tools: ReturnType[]; + resources: ReturnType[]; +}) { + 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 ?? ""} + +`, + ) + .join("\n")} + + +## Resources + +${resources + .map( + (r) => ` + +### ${r.name} + +${r.description ?? ""} +`, + ) + .join("\n")} +`; +} + +async function cleanup() { + await rimraf(config.outFile); +} + +void generate(); From 06300427cb925ada54bf1818c7bded22f57e1427 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 9 Aug 2025 14:16:51 +0200 Subject: [PATCH 017/199] update jsonv-ts, fixed tests --- app/__test__/app/AppServer.spec.ts | 6 ++++++ app/package.json | 2 +- bun.lock | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/__test__/app/AppServer.spec.ts b/app/__test__/app/AppServer.spec.ts index 40ea414..43cf220 100644 --- a/app/__test__/app/AppServer.spec.ts +++ b/app/__test__/app/AppServer.spec.ts @@ -13,6 +13,9 @@ describe("AppServer", () => { allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], }, + mcp: { + enabled: false, + }, }); } @@ -31,6 +34,9 @@ describe("AppServer", () => { allow_methods: ["GET", "POST"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], }, + mcp: { + enabled: false, + }, }); } }); diff --git a/app/package.json b/app/package.json index 3fecc56..de40962 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "0.6.2", + "jsonv-ts": "^0.7.0", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/bun.lock b/bun.lock index 2e0d4ed..a7ddf49 100644 --- a/bun.lock +++ b/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.6.2", + "jsonv-ts": "^0.7.0", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2511,7 +2511,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.6.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-v4rIs0h7hoPiadotSNoLCvKyitUtboizbeydQjfBHb1HJG5ADda+BpNeRFGEGtq0m8405UmpEJ9l2Kt9J4SItQ=="], + "jsonv-ts": ["jsonv-ts@0.7.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-zN5/KMs1WOs+0IbYiZF7mVku4dum8LKP9xv8VqgVm+PBz5VZuU1V8iLQhI991ogUbhGHHlOCwqxnxQUuvCPbQA=="], "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=="], From c1c23d24fec4c7b271cffad4090e027560063b40 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 9 Aug 2025 14:18:44 +0200 Subject: [PATCH 018/199] switch to node api for generate-mcp for cf builds --- docs/scripts/generate-mcp.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/scripts/generate-mcp.ts b/docs/scripts/generate-mcp.ts index 34e798b..6e329a5 100644 --- a/docs/scripts/generate-mcp.ts +++ b/docs/scripts/generate-mcp.ts @@ -1,5 +1,6 @@ import type { Tool, Resource } from "jsonv-ts/mcp"; import { rimraf } from "rimraf"; +import { writeFile, readFile } from "node:fs/promises"; const config = { mcpConfig: "./mcp.json", @@ -9,9 +10,9 @@ const config = { async function generate() { console.info("Generating MCP documentation..."); await cleanup(); - const mcpConfig = await Bun.file(config.mcpConfig).json(); + const mcpConfig = JSON.parse(await readFile(config.mcpConfig, "utf-8")); const document = await generateDocument(mcpConfig); - await Bun.write(config.outFile, document); + await writeFile(config.outFile, document, "utf-8"); console.info("MCP documentation generated."); } From ad04d5273ec135b677dbb7d028cab2ebc999003c Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 9 Aug 2025 14:23:11 +0200 Subject: [PATCH 019/199] docs: update tools, test bun --- docs/package-lock.json | 24 ++++++++++++++++++++++++ docs/package.json | 1 + docs/scripts/generate-mcp.ts | 13 +++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 4d64549..8fb69ff 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -28,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", @@ -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", diff --git a/docs/package.json b/docs/package.json index 02d15bf..346a3be 100644 --- a/docs/package.json +++ b/docs/package.json @@ -36,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", diff --git a/docs/scripts/generate-mcp.ts b/docs/scripts/generate-mcp.ts index 6e329a5..3da6c05 100644 --- a/docs/scripts/generate-mcp.ts +++ b/docs/scripts/generate-mcp.ts @@ -1,3 +1,5 @@ +/// + import type { Tool, Resource } from "jsonv-ts/mcp"; import { rimraf } from "rimraf"; import { writeFile, readFile } from "node:fs/promises"; @@ -9,6 +11,13 @@ const config = { 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); @@ -35,7 +44,7 @@ import { JsonSchemaTypeTable } from '@/components/McpTool'; ${tools .map( (t) => ` -### ${t.name} +### \`${t.name}\` ${t.description ?? ""} @@ -50,7 +59,7 @@ ${resources .map( (r) => ` -### ${r.name} +### \`${r.name}\` ${r.description ?? ""} `, From 822e6fd6443f1e23ac46e27a49549077dff26eed Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 9 Aug 2025 15:07:20 +0200 Subject: [PATCH 020/199] fix tools when auth is disabled + log notifications to console --- app/package.json | 2 +- app/src/modules/ModuleHelper.ts | 7 ++++--- app/src/modules/server/SystemController.ts | 19 +++++++++++++++++++ bun.lock | 4 ++-- examples/cloudflare-worker/src/index.ts | 2 +- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app/package.json b/app/package.json index de40962..72936b9 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.7.0", + "jsonv-ts": "^0.7.1", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index 71031cc..842033d 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -121,9 +121,10 @@ export class ModuleHelper { invariant(c.context.app, "app is not available in mcp context"); invariant(c.raw instanceof Request, "request is not available in mcp context"); - const user = await c.context.app.module.auth.authenticator.resolveAuthFromRequest( - c.raw as Request, - ); + const auth = c.context.app.module.auth; + if (!auth.enabled) return; + + const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as Request); if (!this.ctx.guard.granted(permission, user)) { throw new Exception( diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index dcc5d14..3ec2b76 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -64,6 +64,25 @@ export class SystemController extends Controller { this.registerMcp(); const mcpServer = getSystemMcp(app); + 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({ diff --git a/bun.lock b/bun.lock index a7ddf49..d8bf271 100644 --- a/bun.lock +++ b/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.7.0", + "jsonv-ts": "^0.7.1", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2511,7 +2511,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.7.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-zN5/KMs1WOs+0IbYiZF7mVku4dum8LKP9xv8VqgVm+PBz5VZuU1V8iLQhI991ogUbhGHHlOCwqxnxQUuvCPbQA=="], + "jsonv-ts": ["jsonv-ts@0.7.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-UMqzVRE93NKO/aPROYIbE7yZJxzZ+ab7QaR7Lkxqltkh9ss9c6n8beDxlen71bpsTceLbSxMCbO05r87UMf4JA=="], "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=="], diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index cae6a1b..642fb1f 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -1,7 +1,7 @@ import { serve } from "bknd/adapter/cloudflare"; export default serve({ - mode: "warm", + mode: "fresh", d1: { session: true, }, From 2e145bbf95e573a3ea1e3a464c83e732cd6d692f Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 08:51:32 +0200 Subject: [PATCH 021/199] init mcp tools test --- app/__test__/app/mcp.spec.ts | 42 ---------------- app/__test__/app/mcp/mcp.auth.test.ts | 41 ++++++++++++++++ app/__test__/app/mcp/mcp.base.test.ts | 32 +++++++++++++ app/__test__/app/mcp/mcp.data.test.ts | 48 +++++++++++++++++++ app/__test__/app/mcp/mcp.media.test.ts | 39 +++++++++++++++ app/__test__/app/mcp/mcp.system.test.ts | 38 +++++++++++++++ app/bunfig.toml | 3 +- app/package.json | 2 +- app/src/cli/commands/mcp/mcp.ts | 2 +- app/src/core/test/utils.ts | 15 ++++++ app/src/modules/{server => mcp}/system-mcp.ts | 0 app/src/modules/server/SystemController.ts | 10 +++- bun.lock | 12 +++-- 13 files changed, 233 insertions(+), 51 deletions(-) delete mode 100644 app/__test__/app/mcp.spec.ts create mode 100644 app/__test__/app/mcp/mcp.auth.test.ts create mode 100644 app/__test__/app/mcp/mcp.base.test.ts create mode 100644 app/__test__/app/mcp/mcp.data.test.ts create mode 100644 app/__test__/app/mcp/mcp.media.test.ts create mode 100644 app/__test__/app/mcp/mcp.system.test.ts rename app/src/modules/{server => mcp}/system-mcp.ts (100%) diff --git a/app/__test__/app/mcp.spec.ts b/app/__test__/app/mcp.spec.ts deleted file mode 100644 index 15c19ea..0000000 --- a/app/__test__/app/mcp.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { makeAppFromEnv } from "cli/commands/run"; -import { createApp } from "core/test/utils"; -import { ObjectToolSchema } from "modules/mcp"; -import { s } from "bknd/utils"; - -describe("mcp", () => { - it("...", async () => { - const app = createApp({ - initialConfig: { - auth: { - enabled: true, - }, - }, - }); - await app.build(); - - const appConfig = app.modules.configs(); - const { version, ...appSchema } = app.getSchema(); - - const schema = s.strictObject(appSchema); - - const nodes = [...schema.walk({ data: appConfig })] - .map((n) => { - const path = n.instancePath.join("."); - if (path.startsWith("auth")) { - console.log("schema", n.instancePath, n.schema.constructor.name); - if (path === "auth.jwt") { - //console.log("jwt", n.schema.IS_MCP); - } - } - return n; - }) - .filter((n) => n.schema instanceof ObjectToolSchema) as s.Node[]; - const tools = nodes.flatMap((n) => n.schema.getTools(n)); - - console.log( - "tools", - tools.map((t) => t.name), - ); - }); -}); diff --git a/app/__test__/app/mcp/mcp.auth.test.ts b/app/__test__/app/mcp/mcp.auth.test.ts new file mode 100644 index 0000000..7f3643b --- /dev/null +++ b/app/__test__/app/mcp/mcp.auth.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeAll } from "bun:test"; +import { type App, createApp } from "core/test/utils"; +import { getSystemMcp } from "modules/mcp/system-mcp"; + +/** + * - [ ] auth_me + * - [ ] auth_strategies + * - [ ] auth_user_create + * - [ ] auth_user_token + * - [ ] auth_user_password_change + * - [ ] auth_user_password_test + * - [ ] config_auth_update + * - [ ] config_auth_strategies_get + * - [ ] config_auth_strategies_add + * - [ ] config_auth_strategies_update + * - [ ] config_auth_strategies_remove + * - [ ] config_auth_roles_get + * - [ ] config_auth_roles_add + * - [ ] config_auth_roles_update + * - [ ] config_auth_roles_remove + */ +describe("mcp auth", async () => { + let app: App; + let server: ReturnType; + beforeAll(async () => { + app = createApp({ + initialConfig: { + auth: { + enabled: true, + }, + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + server = getSystemMcp(app); + }); +}); diff --git a/app/__test__/app/mcp/mcp.base.test.ts b/app/__test__/app/mcp/mcp.base.test.ts new file mode 100644 index 0000000..34816e9 --- /dev/null +++ b/app/__test__/app/mcp/mcp.base.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "bun:test"; +import { createApp } from "core/test/utils"; +import { getSystemMcp } from "modules/mcp/system-mcp"; +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: "./", + }, + }, + }, + }, + }); + await app.build(); + + const server = getSystemMcp(app); + expect(server.tools.length).toBeGreaterThan(0); + }); +}); diff --git a/app/__test__/app/mcp/mcp.data.test.ts b/app/__test__/app/mcp/mcp.data.test.ts new file mode 100644 index 0000000..23bf496 --- /dev/null +++ b/app/__test__/app/mcp/mcp.data.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeAll } from "bun:test"; +import { type App, createApp } from "core/test/utils"; +import { getSystemMcp } from "modules/mcp/system-mcp"; + +/** + * - [ ] data_sync + * - [ ] data_entity_fn_count + * - [ ] data_entity_fn_exists + * - [ ] data_entity_read_one + * - [ ] data_entity_read_many + * - [ ] data_entity_insert + * - [ ] data_entity_update_many + * - [ ] data_entity_update_one + * - [ ] data_entity_delete_one + * - [ ] data_entity_delete_many + * - [ ] data_entity_info + * - [ ] config_data_get + * - [ ] config_data_update + * - [ ] config_data_entities_get + * - [ ] config_data_entities_add + * - [ ] config_data_entities_update + * - [ ] config_data_entities_remove + * - [ ] config_data_relations_get + * - [ ] config_data_relations_add + * - [ ] config_data_relations_update + * - [ ] config_data_relations_remove + * - [ ] config_data_indices_get + * - [ ] config_data_indices_add + * - [ ] config_data_indices_update + * - [ ] config_data_indices_remove + */ +describe("mcp data", async () => { + let app: App; + let server: ReturnType; + beforeAll(async () => { + app = createApp({ + initialConfig: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + server = getSystemMcp(app); + }); +}); diff --git a/app/__test__/app/mcp/mcp.media.test.ts b/app/__test__/app/mcp/mcp.media.test.ts new file mode 100644 index 0000000..c2d8669 --- /dev/null +++ b/app/__test__/app/mcp/mcp.media.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, beforeAll } from "bun:test"; +import { type App, createApp } from "core/test/utils"; +import { getSystemMcp } from "modules/mcp/system-mcp"; +import { registries } from "index"; +import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; + +/** + * - [ ] config_media_get + * - [ ] config_media_update + * - [ ] config_media_adapter_get + * - [ ] config_media_adapter_update + */ +describe("mcp media", async () => { + let app: App; + let server: ReturnType; + beforeAll(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 = getSystemMcp(app); + }); +}); diff --git a/app/__test__/app/mcp/mcp.system.test.ts b/app/__test__/app/mcp/mcp.system.test.ts new file mode 100644 index 0000000..9808a51 --- /dev/null +++ b/app/__test__/app/mcp/mcp.system.test.ts @@ -0,0 +1,38 @@ +import { describe, test, expect, beforeAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import { getSystemMcp } from "modules/mcp/system-mcp"; +import { inspect } from "node:util"; +inspect.defaultOptions.depth = 10; + +/** + * - [ ] system_config + * - [ ] system_build + * - [ ] system_ping + * - [ ] system_info + * - [ ] config_server_get + * - [ ] config_server_update + */ +describe("mcp system", async () => { + let app: App; + let server: ReturnType; + beforeAll(async () => { + app = createApp({ + initialConfig: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + server = getSystemMcp(app); + }); + + const tool = createMcpToolCaller(); + + test("system_ping", async () => { + const result = await tool(server, "system_ping", {}); + expect(result).toEqual({ pong: true }); + }); +}); diff --git a/app/bunfig.toml b/app/bunfig.toml index 6f4fe9a..c39b588 100644 --- a/app/bunfig.toml +++ b/app/bunfig.toml @@ -2,4 +2,5 @@ #registry = "http://localhost:4873" [test] -coverageSkipTestFiles = true \ No newline at end of file +coverageSkipTestFiles = true +console.depth = 10 \ No newline at end of file diff --git a/app/package.json b/app/package.json index 72936b9..34c8bbd 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.7.1", + "jsonv-ts": "^0.7.2", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts index 35d86a8..2030413 100644 --- a/app/src/cli/commands/mcp/mcp.ts +++ b/app/src/cli/commands/mcp/mcp.ts @@ -1,6 +1,6 @@ import type { CliCommand } from "cli/types"; import { makeAppFromEnv } from "../run"; -import { getSystemMcp } from "modules/server/system-mcp"; +import { getSystemMcp } from "modules/mcp/system-mcp"; import { $console } from "bknd/utils"; import { stdioTransport } from "jsonv-ts/mcp"; diff --git a/app/src/core/test/utils.ts b/app/src/core/test/utils.ts index d4cefa9..30e0642 100644 --- a/app/src/core/test/utils.ts +++ b/app/src/core/test/utils.ts @@ -1,6 +1,7 @@ import { createApp as createAppInternal, type CreateAppConfig } from "App"; import { bunSqlite } from "adapter/bun/connection/BunSqliteConnection"; import { Connection } from "data/connection/Connection"; +import type { getSystemMcp } from "modules/mcp/system-mcp"; export { App } from "App"; @@ -10,3 +11,17 @@ export function createApp({ connection, ...config }: CreateAppConfig = {}) { connection: Connection.isConnection(connection) ? connection : bunSqlite(connection as any), }); } + +export function createMcpToolCaller() { + return async (server: ReturnType, name: string, args: any) => { + const res = await server.handle({ + jsonrpc: "2.0", + method: "tools/call", + params: { + name, + arguments: args, + }, + }); + return JSON.parse((res.result as any)?.content?.[0]?.text ?? "null"); + }; +} diff --git a/app/src/modules/server/system-mcp.ts b/app/src/modules/mcp/system-mcp.ts similarity index 100% rename from app/src/modules/server/system-mcp.ts rename to app/src/modules/mcp/system-mcp.ts diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 3ec2b76..f5b024c 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -15,6 +15,7 @@ import { openAPISpecs, mcpTool, mcp as mcpMiddleware, + isNode, } from "bknd/utils"; import type { Context, Hono } from "hono"; import { Controller } from "modules/Controller"; @@ -28,7 +29,7 @@ import { import * as SystemPermissions from "modules/permissions"; import { getVersion } from "core/env"; import type { Module } from "modules/Module"; -import { getSystemMcp } from "./system-mcp"; +import { getSystemMcp } from "modules/mcp/system-mcp"; export type ConfigUpdate = { success: true; @@ -94,6 +95,8 @@ export class SystemController extends Controller { }, endpoint: { path: "/mcp", + // @ts-ignore + _init: isNode() ? { duplex: "half" } : {}, }, }), ); @@ -365,7 +368,10 @@ export class SystemController extends Controller { }), (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, diff --git a/bun.lock b/bun.lock index d8bf271..d139a29 100644 --- a/bun.lock +++ b/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.7.1", + "jsonv-ts": "^0.7.2", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -1227,7 +1227,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=="], @@ -2511,7 +2511,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.7.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-UMqzVRE93NKO/aPROYIbE7yZJxzZ+ab7QaR7Lkxqltkh9ss9c6n8beDxlen71bpsTceLbSxMCbO05r87UMf4JA=="], + "jsonv-ts": ["jsonv-ts@0.7.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-HxtHbMQhReJpxDIWHcM+kLekRLgJIo+drQnxiXep9thbh5jA44pd3DxwApEV1/oTufH2xAfDV6uu6O0Fd4s9lA=="], "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=="], @@ -3827,6 +3827,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=="], @@ -4071,7 +4073,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=="], @@ -4677,6 +4679,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=="], From 1e8c373dd485d39ede381a0c85e8bbcf0cf9e641 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 09:17:38 +0200 Subject: [PATCH 022/199] added mcp server tests --- app/__test__/app/mcp/mcp.server.test.ts | 62 +++++++++++++++++++++++++ app/__test__/app/mcp/mcp.system.test.ts | 34 +++++++++++--- app/src/core/test/utils.ts | 5 ++ 3 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 app/__test__/app/mcp/mcp.server.test.ts diff --git a/app/__test__/app/mcp/mcp.server.test.ts b/app/__test__/app/mcp/mcp.server.test.ts new file mode 100644 index 0000000..29113fd --- /dev/null +++ b/app/__test__/app/mcp/mcp.server.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect, beforeAll, mock } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; +import { getSystemMcp } from "modules/mcp/system-mcp"; + +/** + * - [x] config_server_get + * - [x] config_server_update + */ +describe("mcp system", async () => { + let app: App; + let server: ReturnType; + beforeAll(async () => { + app = createApp({ + initialConfig: { + server: { + mcp: { + enabled: true, + }, + }, + }, + }); + await app.build(); + server = getSystemMcp(app); + }); + + const tool = createMcpToolCaller(); + + test("config_server_get", async () => { + const result = await tool(server, "config_server_get", {}); + expect(result).toEqual({ + path: "", + secrets: false, + partial: false, + value: app.toJSON().server, + }); + }); + + test("config_server_update", async () => { + const original = app.toJSON().server; + const result = await tool(server, "config_server_update", { + value: { + cors: { + origin: "http://localhost", + }, + }, + return_config: true, + }); + + expect(result).toEqual({ + success: true, + module: "server", + config: { + ...original, + cors: { + ...original.cors, + origin: "http://localhost", + }, + }, + }); + expect(app.toJSON().server.cors.origin).toBe("http://localhost"); + }); +}); diff --git a/app/__test__/app/mcp/mcp.system.test.ts b/app/__test__/app/mcp/mcp.system.test.ts index 9808a51..60be89b 100644 --- a/app/__test__/app/mcp/mcp.system.test.ts +++ b/app/__test__/app/mcp/mcp.system.test.ts @@ -1,16 +1,15 @@ -import { describe, test, expect, beforeAll } from "bun:test"; +import { AppEvents } from "App"; +import { describe, test, expect, beforeAll, mock } from "bun:test"; import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { getSystemMcp } from "modules/mcp/system-mcp"; import { inspect } from "node:util"; inspect.defaultOptions.depth = 10; /** - * - [ ] system_config - * - [ ] system_build - * - [ ] system_ping - * - [ ] system_info - * - [ ] config_server_get - * - [ ] config_server_update + * - [x] system_config + * - [x] system_build + * - [x] system_ping + * - [x] system_info */ describe("mcp system", async () => { let app: App; @@ -35,4 +34,25 @@ describe("mcp system", async () => { const result = await tool(server, "system_ping", {}); expect(result).toEqual({ pong: true }); }); + + test("system_info", async () => { + const result = await tool(server, "system_info", {}); + expect(Object.keys(result).length).toBeGreaterThan(0); + expect(Object.keys(result)).toContainValues(["version", "runtime", "connection"]); + }); + + test("system_build", async () => { + const called = mock(() => null); + + app.emgr.onEvent(AppEvents.AppBuiltEvent, () => void called(), { once: true }); + + const result = await tool(server, "system_build", {}); + expect(called).toHaveBeenCalledTimes(1); + expect(result.success).toBe(true); + }); + + test("system_config", async () => { + const result = await tool(server, "system_config", {}); + expect(result).toEqual(app.toJSON()); + }); }); diff --git a/app/src/core/test/utils.ts b/app/src/core/test/utils.ts index 30e0642..5702724 100644 --- a/app/src/core/test/utils.ts +++ b/app/src/core/test/utils.ts @@ -22,6 +22,11 @@ export function createMcpToolCaller() { arguments: args, }, }); + + if ((res.result as any)?.isError) { + throw new Error((res.result as any)?.content?.[0]?.text ?? "Unknown error"); + } + return JSON.parse((res.result as any)?.content?.[0]?.text ?? "null"); }; } From f40ea0ec5b41d50040a6664ed1bf3c59e20cbb9c Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 12:55:14 +0200 Subject: [PATCH 023/199] init mcp data tests, added crud for $record --- app/__test__/app/mcp/mcp.data.test.ts | 67 ++++++++++++++++++++++++--- app/package.json | 2 +- app/src/core/test/utils.ts | 1 + app/src/data/data-schema.ts | 18 ++++++- app/src/modules/mcp/$record.ts | 46 ++++++++++++++---- bun.lock | 4 +- 6 files changed, 117 insertions(+), 21 deletions(-) diff --git a/app/__test__/app/mcp/mcp.data.test.ts b/app/__test__/app/mcp/mcp.data.test.ts index 23bf496..4a39d25 100644 --- a/app/__test__/app/mcp/mcp.data.test.ts +++ b/app/__test__/app/mcp/mcp.data.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeAll } from "bun:test"; -import { type App, createApp } from "core/test/utils"; +import { describe, test, expect, beforeAll } from "bun:test"; +import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { getSystemMcp } from "modules/mcp/system-mcp"; /** @@ -16,10 +16,10 @@ import { getSystemMcp } from "modules/mcp/system-mcp"; * - [ ] data_entity_info * - [ ] config_data_get * - [ ] config_data_update - * - [ ] config_data_entities_get - * - [ ] config_data_entities_add - * - [ ] config_data_entities_update - * - [ ] config_data_entities_remove + * - [x] config_data_entities_get + * - [x] config_data_entities_add + * - [x] config_data_entities_update + * - [x] config_data_entities_remove * - [ ] config_data_relations_get * - [ ] config_data_relations_add * - [ ] config_data_relations_update @@ -44,5 +44,60 @@ describe("mcp data", async () => { }); await app.build(); server = getSystemMcp(app); + 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", + value: {}, + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(result.config.entities.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", + value: { + config: { + name: "Test", + }, + }, + }); + expect(result.success).toBe(true); + expect(result.module).toBe("data"); + expect(result.config.entities.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(); + } }); }); diff --git a/app/package.json b/app/package.json index 34c8bbd..a175e79 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.7.2", + "jsonv-ts": "^0.7.3", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/core/test/utils.ts b/app/src/core/test/utils.ts index 5702724..19eaa63 100644 --- a/app/src/core/test/utils.ts +++ b/app/src/core/test/utils.ts @@ -24,6 +24,7 @@ export function createMcpToolCaller() { }); if ((res.result as any)?.isError) { + console.dir(res.result, { depth: null }); throw new Error((res.result as any)?.content?.[0]?.text ?? "Unknown error"); } diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index 6394ff9..a44a4ee 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -3,7 +3,7 @@ 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"; @@ -12,6 +12,7 @@ export const FIELDS = { ...RelationFieldClassMap, media: { schema: mediaFieldConfigSchema, field: MediaField }, }; +export const FIELD_TYPES = Object.keys(FIELDS); export type FieldType = keyof typeof FIELDS; export const RELATIONS = RelationClassMap; @@ -40,6 +41,19 @@ export const entitiesSchema = s.strictObject({ fields: entityFields.optional(), }); export type TAppDataEntity = s.Static; +export const simpleEntitiesSchema = s.strictObject({ + type: s.string({ enum: entityTypes, default: "regular" }).optional(), + config: entityConfigSchema.optional(), + fields: s + .record( + s.object({ + type: s.anyOf([s.string({ enum: FIELD_TYPES }), s.string()]), + config: baseFieldConfigSchema.optional(), + }), + { default: {} }, + ) + .optional(), +}); export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => { return s.strictObject( @@ -70,6 +84,6 @@ export const dataConfigSchema = $object("config_data", { default: {}, }).optional(), indices: $record("config_data_indices", indicesSchema, { default: {} }).optional(), -}); +}).strict(); export type AppDataConfig = s.Static; diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts index 2e60651..4ab7bc3 100644 --- a/app/src/modules/mcp/$record.ts +++ b/app/src/modules/mcp/$record.ts @@ -32,6 +32,10 @@ export class RecordToolSchema< return this[mcpSchemaSymbol]; } + private getNewSchema(fallback: s.Schema = this.additionalProperties) { + return this[opts].new_schema ?? fallback; + } + private toolGet(node: s.Node>) { return new Tool( [this.mcp.name, "get"].join("_"), @@ -60,8 +64,10 @@ export class RecordToolSchema< 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)) { @@ -70,15 +76,19 @@ export class RecordToolSchema< 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, }); }, ); @@ -93,20 +103,26 @@ export class RecordToolSchema< key: s.string({ description: "key to add", }), - value: this[opts].new_schema ?? this.additionalProperties, + value: this.getNewSchema(), }), }, async (params, ctx: AppToolHandlerCtx) => { const configs = ctx.context.app.toJSON(); 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); + return ctx.json({ - key: params.key, - value: params.value ?? null, + success: true, + module: module_name, + config: ctx.context.app.module[module_name as any].config, }); }, ); @@ -121,22 +137,26 @@ export class RecordToolSchema< key: s.string({ description: "key to update", }), - value: this[opts].new_schema ?? s.object({}), + value: this.getNewSchema(s.object({})), }), }, async (params, ctx: AppToolHandlerCtx) => { const configs = ctx.context.app.toJSON(params.secrets); 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`); } - const value = getPath(config, params.key); + await ctx.context.app + .mutateConfig(module_name as any) + .patch([...rest, params.key], params.value); + return ctx.json({ - updated: false, - key: params.key, - value: value ?? null, + success: true, + module: module_name, + config: ctx.context.app.module[module_name as any].config, }); }, ); @@ -156,14 +176,20 @@ export class RecordToolSchema< async (params, ctx: AppToolHandlerCtx) => { const configs = ctx.context.app.toJSON(); 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(".")); + return ctx.json({ - removed: false, - key: params.key, + success: true, + module: module_name, + config: ctx.context.app.module[module_name as any].config, }); }, ); diff --git a/bun.lock b/bun.lock index d139a29..0756dc5 100644 --- a/bun.lock +++ b/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.7.2", + "jsonv-ts": "^0.7.3", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2511,7 +2511,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.7.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-HxtHbMQhReJpxDIWHcM+kLekRLgJIo+drQnxiXep9thbh5jA44pd3DxwApEV1/oTufH2xAfDV6uu6O0Fd4s9lA=="], + "jsonv-ts": ["jsonv-ts@0.7.3", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-1P/ouF/a84Rc7NCXfSGPmkttyBFqemHE+5tZjb7hyaTs8MxmVUkuUO+d80/uu8sguzTnd3MmAuyuLAM0HQT4cA=="], "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=="], From 375d2c205f4ecae84c96b3638f67c2e2dda7cae2 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 15:29:09 +0200 Subject: [PATCH 024/199] adapter(cloudflare): removed `durable` mode, added `withPlatformProxy` (#233) * removed `durable` mode as it requires an import from "cloudflare:" that often fails in non-cf environments * remove worker configuration types * add `withPlatformProxy` * withPlatformProxy: make configuration optional --- app/__test__/adapter/adapter.test.ts | 12 +- app/build.ts | 6 +- app/src/adapter/cloudflare/bindings.ts | 7 +- .../cloudflare-workers.adapter.spec.ts | 4 +- .../cloudflare/cloudflare-workers.adapter.ts | 12 +- app/src/adapter/cloudflare/config.ts | 8 +- app/src/adapter/cloudflare/index.ts | 2 +- app/src/adapter/cloudflare/modes/cached.ts | 2 +- app/src/adapter/cloudflare/modes/durable.ts | 134 ------------------ app/src/adapter/cloudflare/modes/fresh.ts | 2 +- app/src/adapter/cloudflare/proxy.ts | 66 +++++++++ .../cloudflare/storage/StorageR2Adapter.ts | 7 +- app/src/adapter/index.ts | 28 ++-- app/src/cli/commands/run/run.ts | 2 +- app/src/index.ts | 1 + examples/cloudflare-worker/.gitignore | 2 + examples/cloudflare-worker/bknd.config.ts | 12 ++ examples/cloudflare-worker/package.json | 9 +- examples/cloudflare-worker/src/index.ts | 8 +- examples/cloudflare-worker/tsconfig.json | 17 ++- .../worker-configuration.d.ts | 8 -- 21 files changed, 152 insertions(+), 197 deletions(-) delete mode 100644 app/src/adapter/cloudflare/modes/durable.ts create mode 100644 app/src/adapter/cloudflare/proxy.ts create mode 100644 examples/cloudflare-worker/bknd.config.ts delete mode 100644 examples/cloudflare-worker/worker-configuration.d.ts diff --git a/app/__test__/adapter/adapter.test.ts b/app/__test__/adapter/adapter.test.ts index f7d623c..ee9a87e 100644 --- a/app/__test__/adapter/adapter.test.ts +++ b/app/__test__/adapter/adapter.test.ts @@ -9,16 +9,16 @@ beforeAll(disableConsoleLog); afterAll(enableConsoleLog); describe("adapter", () => { - it("makes config", () => { - expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({}); - expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual( - {}, - ); + it("makes config", async () => { + expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({}); + expect( + omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]), + ).toEqual({}); // merges everything returned from `app` with the config expect( omitKeys( - adapter.makeConfig( + await adapter.makeConfig( { app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) }, { env: { TEST: "test" } }, ), diff --git a/app/build.ts b/app/build.ts index f149231..998132c 100644 --- a/app/build.ts +++ b/app/build.ts @@ -256,7 +256,11 @@ async function buildAdapters() { ), tsup.build(baseConfig("astro")), tsup.build(baseConfig("aws")), - tsup.build(baseConfig("cloudflare")), + tsup.build( + baseConfig("cloudflare", { + external: ["wrangler", "node:process"], + }), + ), tsup.build({ ...baseConfig("vite"), diff --git a/app/src/adapter/cloudflare/bindings.ts b/app/src/adapter/cloudflare/bindings.ts index 0b68524..891081e 100644 --- a/app/src/adapter/cloudflare/bindings.ts +++ b/app/src/adapter/cloudflare/bindings.ts @@ -1,3 +1,5 @@ +import { inspect } from "node:util"; + export type BindingTypeMap = { D1Database: D1Database; KVNamespace: KVNamespace; @@ -13,8 +15,9 @@ export function getBindings(env: any, type: T): Bindin for (const key in env) { try { if ( - env[key] && - ((env[key] as any).constructor.name === type || String(env[key]) === `[object ${type}]`) + (env[key] as any).constructor.name === type || + String(env[key]) === `[object ${type}]` || + inspect(env[key]).includes(type) ) { bindings.push({ key, diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts index 5cdde1a..401722c 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -18,7 +18,7 @@ describe("cf adapter", () => { }); it("makes config", async () => { - const staticConfig = makeConfig( + const staticConfig = await makeConfig( { connection: { url: DB_URL }, initialConfig: { data: { basepath: DB_URL } }, @@ -28,7 +28,7 @@ describe("cf adapter", () => { expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); expect(staticConfig.connection).toBeDefined(); - const dynamicConfig = makeConfig( + const dynamicConfig = await makeConfig( { app: (env) => ({ initialConfig: { data: { basepath: env.DB_URL } }, diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 427f8e4..c16094e 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -5,8 +5,7 @@ import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; import { getFresh } from "./modes/fresh"; import { getCached } from "./modes/cached"; -import { getDurable } from "./modes/durable"; -import type { App } from "bknd"; +import type { App, MaybePromise } from "bknd"; import { $console } from "core/utils"; declare global { @@ -17,12 +16,11 @@ declare global { export type CloudflareEnv = Cloudflare.Env; export type CloudflareBkndConfig = RuntimeBkndConfig & { - mode?: "warm" | "fresh" | "cache" | "durable"; - bindings?: (args: Env) => { + mode?: "warm" | "fresh" | "cache"; + bindings?: (args: Env) => MaybePromise<{ kv?: KVNamespace; - dobj?: DurableObjectNamespace; db?: D1Database; - }; + }>; d1?: { session?: boolean; transport?: "header" | "cookie"; @@ -93,8 +91,6 @@ export function serve( case "cache": app = await getCached(config, context); break; - case "durable": - return await getDurable(config, context); default: throw new Error(`Unknown mode ${mode}`); } diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index da5af07..817892e 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -89,7 +89,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig) { } let media_registered: boolean = false; -export function makeConfig( +export async function makeConfig( config: CloudflareBkndConfig, args?: CfMakeConfigArgs, ) { @@ -102,7 +102,7 @@ export function makeConfig( media_registered = true; } - const appConfig = makeAdapterConfig(config, args?.env); + const appConfig = await makeAdapterConfig(config, args?.env); // if connection instance is given, don't do anything // other than checking if D1 session is defined @@ -115,12 +115,12 @@ export function makeConfig( } // if connection is given, try to open with unified sqlite adapter } else if (appConfig.connection) { - appConfig.connection = sqlite(appConfig.connection); + appConfig.connection = sqlite(appConfig.connection) as any; // if connection is not given, but env is set // try to make D1 from bindings } else if (args?.env) { - const bindings = config.bindings?.(args?.env); + const bindings = await config.bindings?.(args?.env); const sessionHelper = d1SessionHelper(config); const sessionId = sessionHelper.get(args.request); let session: D1DatabaseSession | undefined; diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index bc4e294..edf9cb0 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -3,7 +3,6 @@ import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection"; export * from "./cloudflare-workers.adapter"; export { makeApp, getFresh } from "./modes/fresh"; export { getCached } from "./modes/cached"; -export { DurableBkndApp, getDurable } from "./modes/durable"; export { d1Sqlite, type D1ConnectionConfig }; export { getBinding, @@ -15,6 +14,7 @@ export { export { constants } from "./config"; export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter"; export { registries } from "bknd"; +export { withPlatformProxy } from "./proxy"; // for compatibility with old code export function d1( diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index fc1d3c4..fdbed21 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -8,7 +8,7 @@ export async function getCached( args: Context, ) { const { env, ctx } = args; - const { kv } = config.bindings?.(env)!; + const { kv } = await config.bindings?.(env)!; if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); const key = config.key ?? "app"; diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts deleted file mode 100644 index 4812b0c..0000000 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { DurableObject } from "cloudflare:workers"; -import type { App, CreateAppConfig } from "bknd"; -import { createRuntimeApp, makeConfig } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; -import { constants, registerAsyncsExecutionContext } from "../config"; -import { $console } from "core/utils"; - -export async function getDurable( - config: CloudflareBkndConfig, - ctx: Context, -) { - const { dobj } = config.bindings?.(ctx.env)!; - if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings"); - const key = config.key ?? "app"; - - if ([config.onBuilt, config.beforeBuild].some((x) => x)) { - $console.warn("onBuilt and beforeBuild are not supported with DurableObject mode"); - } - - const start = performance.now(); - - const id = dobj.idFromName(key); - const stub = dobj.get(id) as unknown as DurableBkndApp; - - const create_config = makeConfig(config, ctx.env); - - const res = await stub.fire(ctx.request, { - config: create_config, - keepAliveSeconds: config.keepAliveSeconds, - }); - - const headers = new Headers(res.headers); - headers.set("X-TTDO", String(performance.now() - start)); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); -} - -export class DurableBkndApp extends DurableObject { - protected id = Math.random().toString(36).slice(2); - protected app?: App; - protected interval?: any; - - async fire( - request: Request, - options: { - config: CreateAppConfig; - html?: string; - keepAliveSeconds?: number; - setAdminHtml?: boolean; - }, - ) { - let buildtime = 0; - if (!this.app) { - const start = performance.now(); - const config = options.config; - - // change protocol to websocket if libsql - if ( - config?.connection && - "type" in config.connection && - config.connection.type === "libsql" - ) { - //config.connection.config.protocol = "wss"; - } - - this.app = await createRuntimeApp({ - ...config, - onBuilt: async (app) => { - registerAsyncsExecutionContext(app, this.ctx); - app.modules.server.get(constants.do_endpoint, async (c) => { - // @ts-ignore - const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; - return c.json({ - id: this.id, - keepAliveSeconds: options?.keepAliveSeconds ?? 0, - colo: context.colo, - }); - }); - - await this.onBuilt(app); - }, - adminOptions: { html: options.html }, - beforeBuild: async (app) => { - await this.beforeBuild(app); - }, - }); - - buildtime = performance.now() - start; - } - - if (options?.keepAliveSeconds) { - this.keepAlive(options.keepAliveSeconds); - } - - const res = await this.app!.fetch(request); - const headers = new Headers(res.headers); - headers.set("X-BuildTime", buildtime.toString()); - headers.set("X-DO-ID", this.id); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers, - }); - } - - async onBuilt(app: App) {} - - async beforeBuild(app: App) {} - - protected keepAlive(seconds: number) { - if (this.interval) { - clearInterval(this.interval); - } - - let i = 0; - this.interval = setInterval(() => { - i += 1; - if (i === seconds) { - console.log("cleared"); - clearInterval(this.interval); - - // ping every 30 seconds - } else if (i % 30 === 0) { - console.log("ping"); - this.app?.modules.ctx().connection.ping(); - } - }, 1000); - } -} diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index 7fb37e3..5a3ad22 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -7,7 +7,7 @@ export async function makeApp( args?: CfMakeConfigArgs, opts?: RuntimeOptions, ) { - return await createRuntimeApp(makeConfig(config, args), args?.env, opts); + return await createRuntimeApp(await makeConfig(config, args), args?.env, opts); } export async function getFresh( diff --git a/app/src/adapter/cloudflare/proxy.ts b/app/src/adapter/cloudflare/proxy.ts new file mode 100644 index 0000000..60e49ea --- /dev/null +++ b/app/src/adapter/cloudflare/proxy.ts @@ -0,0 +1,66 @@ +import { + d1Sqlite, + getBinding, + registerMedia, + type CloudflareBkndConfig, + type CloudflareEnv, +} from "."; +import type { PlatformProxy } from "wrangler"; +import process from "node:process"; + +export type WithPlatformProxyOptions = { + /** + * By default, proxy is used if the PROXY environment variable is set to 1. + * You can override/force this by setting this option. + */ + useProxy?: boolean; +}; + +export function withPlatformProxy( + config?: CloudflareBkndConfig, + opts?: WithPlatformProxyOptions, +) { + const use_proxy = + typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1"; + let proxy: PlatformProxy | undefined; + + async function getEnv(env?: Env): Promise { + if (use_proxy) { + if (!proxy) { + const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy); + proxy = await getPlatformProxy(); + setTimeout(proxy?.dispose, 1000); + } + return proxy.env as unknown as Env; + } + return env || ({} as Env); + } + + return { + ...config, + beforeBuild: async (app, registries) => { + if (!use_proxy) return; + const env = await getEnv(); + registerMedia(env, registries); + await config?.beforeBuild?.(app, registries); + }, + bindings: async (env) => { + return (await config?.bindings?.(await getEnv(env))) || {}; + }, + app: async (_env) => { + const env = await getEnv(_env); + + if (config?.app === undefined && use_proxy) { + const binding = getBinding(env, "D1Database"); + return { + connection: d1Sqlite({ + binding: binding.value, + }), + }; + } else if (typeof config?.app === "function") { + return config?.app(env); + } + return config?.app || {}; + }, + } satisfies CloudflareBkndConfig; +} diff --git a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index a1edf58..e257b7c 100644 --- a/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -1,4 +1,4 @@ -import { registries, isDebug, guessMimeType } from "bknd"; +import { registries as $registries, isDebug, guessMimeType } from "bknd"; import { getBindings } from "../bindings"; import { s } from "bknd/utils"; import { StorageAdapter, type FileBody } from "bknd"; @@ -12,7 +12,10 @@ export function makeSchema(bindings: string[] = []) { ); } -export function registerMedia(env: Record) { +export function registerMedia( + env: Record, + registries: typeof $registries = $registries, +) { const r2_bindings = getBindings(env, "R2Bucket"); registries.media.register( diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 65c749b..1990b9f 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -1,13 +1,21 @@ -import { config as $config, App, type CreateAppConfig, Connection, guessMimeType } from "bknd"; +import { + config as $config, + App, + type CreateAppConfig, + Connection, + guessMimeType, + type MaybePromise, + registries as $registries, +} from "bknd"; import { $console } from "bknd/utils"; import type { Context, MiddlewareHandler, Next } from "hono"; import type { AdminControllerOptions } from "modules/server/AdminController"; import type { Manifest } from "vite"; export type BkndConfig = CreateAppConfig & { - app?: CreateAppConfig | ((args: Args) => CreateAppConfig); + app?: CreateAppConfig | ((args: Args) => MaybePromise); onBuilt?: (app: App) => Promise; - beforeBuild?: (app: App) => Promise; + beforeBuild?: (app: App, registries?: typeof $registries) => Promise; buildConfig?: Parameters[0]; }; @@ -30,10 +38,10 @@ export type DefaultArgs = { [key: string]: any; }; -export function makeConfig( +export async function makeConfig( config: BkndConfig, args?: Args, -): CreateAppConfig { +): Promise { let additionalConfig: CreateAppConfig = {}; const { app, ...rest } = config; if (app) { @@ -41,7 +49,7 @@ export function makeConfig( if (!args) { throw new Error("args is required when config.app is a function"); } - additionalConfig = app(args); + additionalConfig = await app(args); } else { additionalConfig = app; } @@ -60,7 +68,7 @@ export async function createAdapterApp( ); } - await config.beforeBuild?.(app); + await config.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } @@ -131,7 +139,7 @@ export async function createRuntimeApp( "sync", ); - await config.beforeBuild?.(app); + await config.beforeBuild?.(app, $registries); await app.build(config.buildConfig); } diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 24c14b5..23e0caf 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -77,7 +77,7 @@ async function makeApp(config: MakeAppConfig) { } export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { - const config = makeConfig(_config, process.env); + const config = await makeConfig(_config, process.env); return makeApp({ ...config, server: { platform }, diff --git a/app/src/index.ts b/app/src/index.ts index 3a7b4d1..28ebbe9 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -39,6 +39,7 @@ export { registries } from "modules/registries"; /** * Core */ +export type { MaybePromise } from "core/types"; export { Exception, BkndError } from "core/errors"; export { isDebug, env } from "core/env"; export { type PrimaryFieldType, config, type DB, type AppEntity } from "core/config"; diff --git a/examples/cloudflare-worker/.gitignore b/examples/cloudflare-worker/.gitignore index 3b0fe33..6fd7128 100644 --- a/examples/cloudflare-worker/.gitignore +++ b/examples/cloudflare-worker/.gitignore @@ -170,3 +170,5 @@ dist .dev.vars .wrangler/ +bknd-types.d.ts +worker-configuration.d.ts \ No newline at end of file diff --git a/examples/cloudflare-worker/bknd.config.ts b/examples/cloudflare-worker/bknd.config.ts new file mode 100644 index 0000000..af5eea2 --- /dev/null +++ b/examples/cloudflare-worker/bknd.config.ts @@ -0,0 +1,12 @@ +/** + * Optionally wrapping the configuration with the `withPlatformProxy` function + * enables programmatic access to the bindings, e.g. for generating types. + */ + +import { withPlatformProxy } from "bknd/adapter/cloudflare"; + +export default withPlatformProxy({ + d1: { + session: true, + }, +}); diff --git a/examples/cloudflare-worker/package.json b/examples/cloudflare-worker/package.json index d283159..02faf9d 100644 --- a/examples/cloudflare-worker/package.json +++ b/examples/cloudflare-worker/package.json @@ -2,16 +2,19 @@ "name": "cloudflare-worker", "version": "0.0.0", "private": true, + "type": "module", "scripts": { "deploy": "wrangler deploy", "dev": "wrangler dev", - "typegen": "wrangler types" + "bknd-typegen": "PROXY=1 npx bknd types", + "typegen": "wrangler types && npm run bknd-typegen", + "predev": "npm run typegen" }, "dependencies": { "bknd": "file:../../app" }, "devDependencies": { - "typescript": "^5.8.3", - "wrangler": "^4.19.1" + "typescript": "^5.9.2", + "wrangler": "^4.28.1" } } diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index cae6a1b..ebbfc8b 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -1,8 +1,4 @@ import { serve } from "bknd/adapter/cloudflare"; +import config from "../bknd.config"; -export default serve({ - mode: "warm", - d1: { - session: true, - }, -}); +export default serve(config); diff --git a/examples/cloudflare-worker/tsconfig.json b/examples/cloudflare-worker/tsconfig.json index 9879d52..d3f6143 100644 --- a/examples/cloudflare-worker/tsconfig.json +++ b/examples/cloudflare-worker/tsconfig.json @@ -1,11 +1,9 @@ { "compilerOptions": { - "target": "es2021", - "lib": ["es2021"], - "jsx": "react-jsx", - "module": "es2022", - "moduleResolution": "Bundler", - "types": ["./worker-configuration.d.ts"], + "lib": ["ES2022"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", "resolveJsonModule": true, "allowJs": true, "checkJs": false, @@ -18,5 +16,10 @@ "skipLibCheck": true }, "exclude": ["test"], - "include": ["worker-configuration.d.ts", "src/**/*.ts"] + "include": [ + "worker-configuration.d.ts", + "bknd-types.d.ts", + "bknd.config.ts", + "src/**/*.ts" + ] } diff --git a/examples/cloudflare-worker/worker-configuration.d.ts b/examples/cloudflare-worker/worker-configuration.d.ts deleted file mode 100644 index 9f7bd6c..0000000 --- a/examples/cloudflare-worker/worker-configuration.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// placeholder, run generation again -declare namespace Cloudflare { - interface Env { - BUCKET: R2Bucket; - DB: D1Database; - } -} -interface Env extends Cloudflare.Env {} From 725a2d453aba87a466ac4340d8c94c5e8fdda9b1 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 15:30:45 +0200 Subject: [PATCH 025/199] adapter(cf): fix exports and type name for do db (#232) --- app/src/adapter/cloudflare/connection/DoConnection.ts | 8 ++++---- app/src/adapter/cloudflare/index.ts | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/adapter/cloudflare/connection/DoConnection.ts b/app/src/adapter/cloudflare/connection/DoConnection.ts index 91ae5ec..5a13b91 100644 --- a/app/src/adapter/cloudflare/connection/DoConnection.ts +++ b/app/src/adapter/cloudflare/connection/DoConnection.ts @@ -3,16 +3,16 @@ import { genericSqlite, type GenericSqliteConnection } from "bknd"; import type { QueryResult } from "kysely"; -export type D1SqliteConnection = GenericSqliteConnection; +export type DoSqliteConnection = GenericSqliteConnection; export type DurableObjecSql = DurableObjectState["storage"]["sql"]; -export type D1ConnectionConfig = +export type DoConnectionConfig = | DurableObjectState | { sql: DB; }; -export function doSqlite(config: D1ConnectionConfig) { +export function doSqlite(config: DoConnectionConfig) { const db = "sql" in config ? config.sql : config.storage.sql; return genericSqlite( @@ -21,7 +21,7 @@ export function doSqlite(config: D1ConnectionConfig< (utils) => { // must be async to work with the miniflare mock const getStmt = async (sql: string, parameters?: any[] | readonly any[]) => - await db.exec(sql, ...(parameters || [])); + db.exec(sql, ...(parameters || [])); const mapResult = ( cursor: SqlStorageCursor>, diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index edf9cb0..88f7413 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -4,6 +4,7 @@ export * from "./cloudflare-workers.adapter"; export { makeApp, getFresh } from "./modes/fresh"; export { getCached } from "./modes/cached"; export { d1Sqlite, type D1ConnectionConfig }; +export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection"; export { getBinding, getBindings, From 97d6af3792503aa2d8f6515efcf51c7c398780e1 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 16:12:10 +0200 Subject: [PATCH 026/199] cleaned up left over bknd/utils imports (#235) --- app/src/Api.ts | 2 +- app/src/App.ts | 2 +- app/src/adapter/cloudflare/cloudflare-workers.adapter.ts | 2 +- app/src/adapter/cloudflare/config.ts | 2 +- app/src/auth/AppAuth.ts | 2 +- app/src/auth/AppUserPool.ts | 2 +- app/src/auth/authenticate/strategies/PasswordStrategy.ts | 3 +-- app/src/auth/authorize/Guard.ts | 2 +- app/src/cli/commands/create/templates/cloudflare.ts | 5 ++--- app/src/cli/commands/run/platform.ts | 2 +- app/src/cli/commands/user.ts | 3 +-- app/src/cli/utils/sys.ts | 2 +- app/src/core/drivers/email/mailchannels.ts | 2 +- app/src/core/events/EventManager.ts | 2 +- app/src/data/AppData.ts | 2 +- app/src/data/connection/connection-test-suite.ts | 2 +- app/src/data/data-schema.ts | 2 +- app/src/data/entities/EntityTypescript.ts | 2 +- app/src/data/entities/Result.ts | 2 +- app/src/data/entities/mutation/MutatorResult.ts | 2 +- app/src/data/entities/query/Repository.ts | 2 +- app/src/data/entities/query/RepositoryResult.ts | 2 +- app/src/data/entities/query/WithBuilder.ts | 2 +- app/src/data/fields/BooleanField.ts | 3 +-- app/src/data/fields/DateField.ts | 4 +--- app/src/data/fields/EnumField.ts | 2 +- app/src/data/fields/JsonField.ts | 2 +- app/src/data/fields/JsonSchemaField.ts | 2 +- app/src/data/fields/NumberField.ts | 3 +-- app/src/data/fields/TextField.ts | 3 +-- app/src/data/schema/constructor.ts | 2 +- app/src/data/server/query.ts | 3 +-- app/src/flows/flows-schema.ts | 3 +-- app/src/flows/flows/Execution.ts | 2 +- app/src/flows/flows/Flow.ts | 2 +- app/src/flows/flows/executors/RuntimeExecutor.ts | 2 +- app/src/flows/flows/triggers/EventTrigger.ts | 3 +-- app/src/flows/tasks/TaskConnection.ts | 2 +- app/src/flows/tasks/presets/LogTask.ts | 3 +-- app/src/media/AppMedia.ts | 2 +- app/src/media/storage/Storage.ts | 2 +- .../storage/adapters/cloudinary/StorageCloudinaryAdapter.ts | 3 +-- app/src/modules/migrations.ts | 2 +- app/src/ui/client/schema/actions.ts | 2 +- app/src/ui/components/form/json-schema-form/utils.ts | 4 ++-- 45 files changed, 47 insertions(+), 60 deletions(-) diff --git a/app/src/Api.ts b/app/src/Api.ts index cce9156..d9fb8e4 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -4,7 +4,7 @@ import { DataApi, type DataApiOptions } from "data/api/DataApi"; import { decode } from "hono/jwt"; import { MediaApi, type MediaApiOptions } from "media/api/MediaApi"; import { SystemApi } from "modules/SystemApi"; -import { omitKeys } from "core/utils"; +import { omitKeys } from "bknd/utils"; import type { BaseModuleApiOptions } from "modules"; export type TApiUser = SafeUser; diff --git a/app/src/App.ts b/app/src/App.ts index 832ed70..7f84a6a 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,5 +1,5 @@ import type { CreateUserPayload } from "auth/AppAuth"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { Event } from "core/events"; import type { em as prototypeEm } from "data/prototype"; import { Connection } from "data/connection/Connection"; diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index c16094e..4cd03ca 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -6,7 +6,7 @@ import { serveStatic } from "hono/cloudflare-workers"; import { getFresh } from "./modes/fresh"; import { getCached } from "./modes/cached"; import type { App, MaybePromise } from "bknd"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; declare global { namespace Cloudflare { diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index 817892e..0a70249 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -9,7 +9,7 @@ import { d1Sqlite } from "./connection/D1Connection"; import type { CloudflareBkndConfig, CloudflareEnv } from "."; import { App } from "bknd"; import type { Context, ExecutionContext } from "hono"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { setCookie } from "hono/cookie"; export const constants = { diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 8ee5423..df8cd03 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -2,7 +2,7 @@ import type { DB } from "bknd"; import * as AuthPermissions from "auth/auth-permissions"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; -import { $console, secureRandomString, transformObject } from "core/utils"; +import { $console, secureRandomString, transformObject } from "bknd/utils"; import type { Entity, EntityManager } from "data/entities"; import { em, entity, enumm, type FieldSchema } from "data/prototype"; import { Module } from "modules/Module"; diff --git a/app/src/auth/AppUserPool.ts b/app/src/auth/AppUserPool.ts index 128de6c..d5679b6 100644 --- a/app/src/auth/AppUserPool.ts +++ b/app/src/auth/AppUserPool.ts @@ -1,6 +1,6 @@ import { AppAuth } from "auth/AppAuth"; import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { pick } from "lodash-es"; import { InvalidConditionsException, diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 1ee6d36..e7e97b8 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,11 +1,10 @@ import type { User } from "bknd"; import type { Authenticator } from "auth/authenticate/Authenticator"; import { InvalidCredentialsException } from "auth/errors"; -import { hash, $console } from "core/utils"; +import { hash, $console, s, parse, jsc } from "bknd/utils"; import { Hono } from "hono"; import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import { AuthStrategy } from "./Strategy"; -import { s, parse, jsc } from "bknd/utils"; const schema = s .object({ diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 09d36fb..a89b98d 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,5 +1,5 @@ import { Exception } from "core/errors"; -import { $console, objectTransform } from "core/utils"; +import { $console, objectTransform } from "bknd/utils"; import { Permission } from "core/security/Permission"; import type { Context } from "hono"; import type { ServerEnv } from "modules/Controller"; diff --git a/app/src/cli/commands/create/templates/cloudflare.ts b/app/src/cli/commands/create/templates/cloudflare.ts index 0bbab03..1cac856 100644 --- a/app/src/cli/commands/create/templates/cloudflare.ts +++ b/app/src/cli/commands/create/templates/cloudflare.ts @@ -1,7 +1,6 @@ import * as $p from "@clack/prompts"; -import { overrideJson, overridePackageJson } from "cli/commands/create/npm"; -import { typewriter, wait } from "cli/utils/cli"; -import { uuid } from "core/utils"; +import { overrideJson } from "cli/commands/create/npm"; +import { typewriter } from "cli/utils/cli"; import c from "picocolors"; import type { Template, TemplateSetupCtx } from "."; import { exec } from "cli/utils/sys"; diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index bc3379b..061d44c 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import type { MiddlewareHandler } from "hono"; import open from "open"; import { fileExists, getRelativeDistPath } from "../../utils/sys"; diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 4f4db7c..e7e23b7 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -9,9 +9,8 @@ import type { PasswordStrategy } from "auth/authenticate/strategies"; import { makeAppFromEnv } from "cli/commands/run"; import type { CliCommand } from "cli/types"; import { Argument } from "commander"; -import { $console } from "core/utils"; +import { $console, isBun } from "bknd/utils"; import c from "picocolors"; -import { isBun } from "core/utils"; export const user: CliCommand = (program) => { program diff --git a/app/src/cli/utils/sys.ts b/app/src/cli/utils/sys.ts index 56ae32e..e7f07c6 100644 --- a/app/src/cli/utils/sys.ts +++ b/app/src/cli/utils/sys.ts @@ -1,4 +1,4 @@ -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { execSync, exec as nodeExec } from "node:child_process"; import { readFile, writeFile as nodeWriteFile } from "node:fs/promises"; import path from "node:path"; diff --git a/app/src/core/drivers/email/mailchannels.ts b/app/src/core/drivers/email/mailchannels.ts index 7478ef5..540355a 100644 --- a/app/src/core/drivers/email/mailchannels.ts +++ b/app/src/core/drivers/email/mailchannels.ts @@ -1,4 +1,4 @@ -import { mergeObject, type RecursivePartial } from "core/utils"; +import { mergeObject, type RecursivePartial } from "bknd/utils"; import type { IEmailDriver } from "./index"; export type MailchannelsEmailOptions = { diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 78db931..8370f2b 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -1,6 +1,6 @@ import { type Event, type EventClass, InvalidEventReturn } from "./Event"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; export type RegisterListenerConfig = | ListenerMode diff --git a/app/src/data/AppData.ts b/app/src/data/AppData.ts index 0b4e464..13dbcc3 100644 --- a/app/src/data/AppData.ts +++ b/app/src/data/AppData.ts @@ -1,4 +1,4 @@ -import { transformObject } from "core/utils"; +import { transformObject } from "bknd/utils"; import { Module } from "modules/Module"; import { DataController } from "./api/DataController"; diff --git a/app/src/data/connection/connection-test-suite.ts b/app/src/data/connection/connection-test-suite.ts index af1eeba..aed28aa 100644 --- a/app/src/data/connection/connection-test-suite.ts +++ b/app/src/data/connection/connection-test-suite.ts @@ -1,6 +1,6 @@ import type { TestRunner } from "core/test"; import { Connection, type FieldSpec } from "./Connection"; -import { getPath } from "core/utils"; +import { getPath } from "bknd/utils"; import * as proto from "data/prototype"; import { createApp } from "App"; import type { MaybePromise } from "core/types"; diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index 7b5c0d8..b1750be 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -1,4 +1,4 @@ -import { objectTransform } from "core/utils"; +import { objectTransform } from "bknd/utils"; import { MediaField, mediaFieldConfigSchema } from "../media/MediaField"; import { FieldClassMap } from "data/fields"; import { RelationClassMap, RelationFieldClassMap } from "data/relations"; diff --git a/app/src/data/entities/EntityTypescript.ts b/app/src/data/entities/EntityTypescript.ts index 85255b9..b7e2b2c 100644 --- a/app/src/data/entities/EntityTypescript.ts +++ b/app/src/data/entities/EntityTypescript.ts @@ -1,6 +1,6 @@ import type { Entity, EntityManager, TEntityType } from "data/entities"; import type { EntityRelation } from "data/relations"; -import { autoFormatString } from "core/utils"; +import { autoFormatString } from "bknd/utils"; import { usersFields } from "auth/auth-entities"; import { mediaFields } from "media/media-entities"; diff --git a/app/src/data/entities/Result.ts b/app/src/data/entities/Result.ts index 0570aa6..2816efd 100644 --- a/app/src/data/entities/Result.ts +++ b/app/src/data/entities/Result.ts @@ -1,5 +1,5 @@ import { isDebug } from "core/env"; -import { pick } from "core/utils"; +import { pick } from "bknd/utils"; import type { Connection } from "data/connection"; import type { Compilable, diff --git a/app/src/data/entities/mutation/MutatorResult.ts b/app/src/data/entities/mutation/MutatorResult.ts index 551bd61..e9e876e 100644 --- a/app/src/data/entities/mutation/MutatorResult.ts +++ b/app/src/data/entities/mutation/MutatorResult.ts @@ -1,4 +1,4 @@ -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import type { Entity, EntityData } from "../Entity"; import type { EntityManager } from "../EntityManager"; import { Result, type ResultJSON, type ResultOptions } from "../Result"; diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 5f85d80..13554a6 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -1,5 +1,5 @@ import type { DB as DefaultDB, PrimaryFieldType } from "bknd"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { type EmitsEvents, EventManager } from "core/events"; import { type SelectQueryBuilder, sql } from "kysely"; import { InvalidSearchParamsException } from "../../errors"; diff --git a/app/src/data/entities/query/RepositoryResult.ts b/app/src/data/entities/query/RepositoryResult.ts index 7631f8f..85dc2eb 100644 --- a/app/src/data/entities/query/RepositoryResult.ts +++ b/app/src/data/entities/query/RepositoryResult.ts @@ -2,7 +2,7 @@ import type { Entity, EntityData } from "../Entity"; import type { EntityManager } from "../EntityManager"; import { Result, type ResultJSON, type ResultOptions } from "../Result"; import type { Compilable, SelectQueryBuilder } from "kysely"; -import { $console, ensureInt } from "core/utils"; +import { $console, ensureInt } from "bknd/utils"; export type RepositoryResultOptions = ResultOptions & { silent?: boolean; diff --git a/app/src/data/entities/query/WithBuilder.ts b/app/src/data/entities/query/WithBuilder.ts index 3f6dde3..5e9fd6a 100644 --- a/app/src/data/entities/query/WithBuilder.ts +++ b/app/src/data/entities/query/WithBuilder.ts @@ -1,4 +1,4 @@ -import { isObject } from "core/utils"; +import { isObject } from "bknd/utils"; import type { KyselyJsonFrom } from "data/relations/EntityRelation"; import type { RepoQuery } from "data/server/query"; diff --git a/app/src/data/fields/BooleanField.ts b/app/src/data/fields/BooleanField.ts index 1655a89..860dbe4 100644 --- a/app/src/data/fields/BooleanField.ts +++ b/app/src/data/fields/BooleanField.ts @@ -1,8 +1,7 @@ -import { omitKeys } from "core/utils"; +import { omitKeys, s } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; -import { s } from "bknd/utils"; export const booleanFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/fields/DateField.ts b/app/src/data/fields/DateField.ts index 0624986..20d152e 100644 --- a/app/src/data/fields/DateField.ts +++ b/app/src/data/fields/DateField.ts @@ -1,9 +1,7 @@ -import { dayjs } from "core/utils"; +import { dayjs, $console, s } from "bknd/utils"; import type { EntityManager } from "../entities"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; -import { $console } from "core/utils"; import type { TFieldTSType } from "data/entities/EntityTypescript"; -import { s } from "bknd/utils"; export const dateFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/fields/EnumField.ts b/app/src/data/fields/EnumField.ts index 306674c..5b2e10f 100644 --- a/app/src/data/fields/EnumField.ts +++ b/app/src/data/fields/EnumField.ts @@ -1,4 +1,4 @@ -import { omitKeys } from "core/utils"; +import { omitKeys } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field"; diff --git a/app/src/data/fields/JsonField.ts b/app/src/data/fields/JsonField.ts index 711767f..c54854b 100644 --- a/app/src/data/fields/JsonField.ts +++ b/app/src/data/fields/JsonField.ts @@ -1,4 +1,4 @@ -import { omitKeys } from "core/utils"; +import { omitKeys } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; diff --git a/app/src/data/fields/JsonSchemaField.ts b/app/src/data/fields/JsonSchemaField.ts index 76ad00a..fed47bf 100644 --- a/app/src/data/fields/JsonSchemaField.ts +++ b/app/src/data/fields/JsonSchemaField.ts @@ -1,5 +1,5 @@ import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema"; -import { objectToJsLiteral } from "core/utils"; +import { objectToJsLiteral } from "bknd/utils"; import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; diff --git a/app/src/data/fields/NumberField.ts b/app/src/data/fields/NumberField.ts index 4f6e53c..b2e4516 100644 --- a/app/src/data/fields/NumberField.ts +++ b/app/src/data/fields/NumberField.ts @@ -2,8 +2,7 @@ import type { EntityManager } from "data/entities"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; import type { TFieldTSType } from "data/entities/EntityTypescript"; -import { s } from "bknd/utils"; -import { omitKeys } from "core/utils"; +import { s, omitKeys } from "bknd/utils"; export const numberFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/fields/TextField.ts b/app/src/data/fields/TextField.ts index 94674fb..9567899 100644 --- a/app/src/data/fields/TextField.ts +++ b/app/src/data/fields/TextField.ts @@ -1,8 +1,7 @@ import type { EntityManager } from "data/entities"; -import { omitKeys } from "core/utils"; +import { omitKeys, s } from "bknd/utils"; import { TransformPersistFailedException } from "../errors"; import { Field, type TActionContext, baseFieldConfigSchema } from "./Field"; -import { s } from "bknd/utils"; export const textFieldConfigSchema = s .strictObject({ diff --git a/app/src/data/schema/constructor.ts b/app/src/data/schema/constructor.ts index 7742812..98cc2fa 100644 --- a/app/src/data/schema/constructor.ts +++ b/app/src/data/schema/constructor.ts @@ -1,4 +1,4 @@ -import { transformObject } from "core/utils"; +import { transformObject } from "bknd/utils"; import { Entity } from "data/entities"; import type { Field } from "data/fields"; import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema"; diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts index f8ba0c0..15ff95f 100644 --- a/app/src/data/server/query.ts +++ b/app/src/data/server/query.ts @@ -1,6 +1,5 @@ -import { s } from "bknd/utils"; +import { s, isObject, $console } from "bknd/utils"; import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder"; -import { isObject, $console } from "core/utils"; import type { anyOf, CoercionOptions, Schema } from "jsonv-ts"; // ------- diff --git a/app/src/flows/flows-schema.ts b/app/src/flows/flows-schema.ts index f430c6c..5d897c8 100644 --- a/app/src/flows/flows-schema.ts +++ b/app/src/flows/flows-schema.ts @@ -1,6 +1,5 @@ -import { transformObject } from "core/utils"; +import { transformObject, s } from "bknd/utils"; import { TaskMap, TriggerMap } from "flows"; -import { s } from "bknd/utils"; export const TASKS = { ...TaskMap, diff --git a/app/src/flows/flows/Execution.ts b/app/src/flows/flows/Execution.ts index 41d2166..7c8ef86 100644 --- a/app/src/flows/flows/Execution.ts +++ b/app/src/flows/flows/Execution.ts @@ -2,7 +2,7 @@ import { Event, EventManager, type ListenerHandler } from "core/events"; import type { EmitsEvents } from "core/events"; import type { Task, TaskResult } from "../tasks/Task"; import type { Flow } from "./Flow"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; export type TaskLog = TaskResult & { task: Task; diff --git a/app/src/flows/flows/Flow.ts b/app/src/flows/flows/Flow.ts index cf6a00b..2a1821a 100644 --- a/app/src/flows/flows/Flow.ts +++ b/app/src/flows/flows/Flow.ts @@ -1,4 +1,4 @@ -import { $console, transformObject } from "core/utils"; +import { $console, transformObject } from "bknd/utils"; import { type TaskMapType, TriggerMap } from "../index"; import type { Task } from "../tasks/Task"; import { Condition, TaskConnection } from "../tasks/TaskConnection"; diff --git a/app/src/flows/flows/executors/RuntimeExecutor.ts b/app/src/flows/flows/executors/RuntimeExecutor.ts index 55bf890..65888c7 100644 --- a/app/src/flows/flows/executors/RuntimeExecutor.ts +++ b/app/src/flows/flows/executors/RuntimeExecutor.ts @@ -1,5 +1,5 @@ import type { Task } from "../../tasks/Task"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; export class RuntimeExecutor { async run( diff --git a/app/src/flows/flows/triggers/EventTrigger.ts b/app/src/flows/flows/triggers/EventTrigger.ts index f17fd69..d1e5b82 100644 --- a/app/src/flows/flows/triggers/EventTrigger.ts +++ b/app/src/flows/flows/triggers/EventTrigger.ts @@ -1,8 +1,7 @@ import type { EventManager } from "core/events"; import type { Flow } from "../Flow"; import { Trigger } from "./Trigger"; -import { $console } from "core/utils"; -import { s } from "bknd/utils"; +import { $console, s } from "bknd/utils"; export class EventTrigger extends Trigger { override type = "event"; diff --git a/app/src/flows/tasks/TaskConnection.ts b/app/src/flows/tasks/TaskConnection.ts index 186eb28..d44b3f9 100644 --- a/app/src/flows/tasks/TaskConnection.ts +++ b/app/src/flows/tasks/TaskConnection.ts @@ -1,4 +1,4 @@ -import { objectCleanEmpty, uuid } from "core/utils"; +import { objectCleanEmpty, uuid } from "bknd/utils"; import { get } from "lodash-es"; import type { Task, TaskResult } from "./Task"; diff --git a/app/src/flows/tasks/presets/LogTask.ts b/app/src/flows/tasks/presets/LogTask.ts index 63b9677..05fc9f9 100644 --- a/app/src/flows/tasks/presets/LogTask.ts +++ b/app/src/flows/tasks/presets/LogTask.ts @@ -1,6 +1,5 @@ import { Task } from "../Task"; -import { $console } from "core/utils"; -import { s } from "bknd/utils"; +import { $console, s } from "bknd/utils"; export class LogTask extends Task { type = "log"; diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index a699d25..4cba790 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,5 +1,5 @@ import type { AppEntity, FileUploadedEventData, StorageAdapter } from "bknd"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import type { Entity, EntityManager } from "data/entities"; import { Storage } from "media/storage/Storage"; import { Module } from "modules/Module"; diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index e364daa..893e25f 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -1,5 +1,5 @@ import { type EmitsEvents, EventManager } from "core/events"; -import { $console, isFile, detectImageDimensions } from "core/utils"; +import { $console, isFile, detectImageDimensions } from "bknd/utils"; import { isMimeType } from "media/storage/mime-types-tiny"; import * as StorageEvents from "./events"; import type { FileUploadedEventData } from "./events"; diff --git a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts index 96ec791..105dfef 100644 --- a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.ts @@ -1,7 +1,6 @@ -import { hash, pickHeaders } from "core/utils"; +import { hash, pickHeaders, s, parse } from "bknd/utils"; import type { FileBody, FileListObject, FileMeta } from "../../Storage"; import { StorageAdapter } from "../../StorageAdapter"; -import { s, parse } from "bknd/utils"; export const cloudinaryAdapterConfig = s.object( { diff --git a/app/src/modules/migrations.ts b/app/src/modules/migrations.ts index 3ce4ffb..e2834eb 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -1,4 +1,4 @@ -import { transformObject } from "core/utils"; +import { transformObject } from "bknd/utils"; import type { Kysely } from "kysely"; import { set } from "lodash-es"; diff --git a/app/src/ui/client/schema/actions.ts b/app/src/ui/client/schema/actions.ts index aa64003..3b8e619 100644 --- a/app/src/ui/client/schema/actions.ts +++ b/app/src/ui/client/schema/actions.ts @@ -1,6 +1,6 @@ import { type NotificationData, notifications } from "@mantine/notifications"; import type { Api } from "Api"; -import { ucFirst } from "core/utils"; +import { ucFirst } from "bknd/utils"; import type { ModuleConfigs } from "modules"; import type { ResponseObject } from "modules/ModuleApi"; import type { ConfigUpdateResponse } from "modules/server/SystemController"; diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts index 81b4a92..6f3e206 100644 --- a/app/src/ui/components/form/json-schema-form/utils.ts +++ b/app/src/ui/components/form/json-schema-form/utils.ts @@ -1,9 +1,9 @@ -import { autoFormatString, omitKeys } from "core/utils"; +import { autoFormatString, omitKeys } from "bknd/utils"; import { type Draft, Draft2019, type JsonSchema } from "json-schema-library"; import type { JSONSchema } from "json-schema-to-ts"; import type { JSONSchemaType } from "json-schema-to-ts/lib/types/definitions/jsonSchema"; -export { isEqual, getPath } from "core/utils/objects"; +export { isEqual, getPath } from "bknd/utils"; export function isNotDefined(value: any) { return value === null || value === undefined || value === ""; From bd3d2ea900dabf44870fc54edcea8e50f84b6b5e Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 20:22:38 +0200 Subject: [PATCH 027/199] added data mcp tests --- app/__test__/app/mcp/mcp.auth.test.ts | 2 +- app/__test__/app/mcp/mcp.data.test.ts | 282 +++++++++++++++++++++++-- app/package.json | 2 +- app/src/data/api/DataController.ts | 17 +- app/src/data/data-schema.ts | 5 +- app/src/modules/mcp/$record.ts | 53 ++++- app/src/modules/mcp/McpSchemaHelper.ts | 10 +- bun.lock | 4 +- docs/mcp.json | 155 ++++++++------ 9 files changed, 425 insertions(+), 105 deletions(-) diff --git a/app/__test__/app/mcp/mcp.auth.test.ts b/app/__test__/app/mcp/mcp.auth.test.ts index 7f3643b..1ae032e 100644 --- a/app/__test__/app/mcp/mcp.auth.test.ts +++ b/app/__test__/app/mcp/mcp.auth.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll } from "bun:test"; +import { describe, beforeAll } from "bun:test"; import { type App, createApp } from "core/test/utils"; import { getSystemMcp } from "modules/mcp/system-mcp"; diff --git a/app/__test__/app/mcp/mcp.data.test.ts b/app/__test__/app/mcp/mcp.data.test.ts index 4a39d25..580f729 100644 --- a/app/__test__/app/mcp/mcp.data.test.ts +++ b/app/__test__/app/mcp/mcp.data.test.ts @@ -1,38 +1,44 @@ -import { describe, test, expect, beforeAll } from "bun:test"; +import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test"; import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { getSystemMcp } from "modules/mcp/system-mcp"; +import { pickKeys } from "bknd/utils"; +import { entity, text } from "bknd"; +import { disableConsoleLog, enableConsoleLog } from "core/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); /** * - [ ] data_sync - * - [ ] data_entity_fn_count - * - [ ] data_entity_fn_exists - * - [ ] data_entity_read_one - * - [ ] data_entity_read_many - * - [ ] data_entity_insert - * - [ ] data_entity_update_many - * - [ ] data_entity_update_one - * - [ ] data_entity_delete_one - * - [ ] data_entity_delete_many - * - [ ] data_entity_info + * - [x] data_entity_fn_count + * - [x] data_entity_fn_exists + * - [x] data_entity_read_one + * - [x] data_entity_read_many + * - [x] data_entity_insert + * - [x] data_entity_update_many + * - [x] data_entity_update_one + * - [x] data_entity_delete_one + * - [x] data_entity_delete_many + * - [x] data_entity_info * - [ ] config_data_get * - [ ] config_data_update * - [x] config_data_entities_get * - [x] config_data_entities_add * - [x] config_data_entities_update * - [x] config_data_entities_remove - * - [ ] config_data_relations_get - * - [ ] config_data_relations_add - * - [ ] config_data_relations_update - * - [ ] config_data_relations_remove - * - [ ] config_data_indices_get - * - [ ] config_data_indices_add - * - [ ] config_data_indices_update - * - [ ] config_data_indices_remove + * - [x] config_data_relations_add + * - [x] config_data_relations_get + * - [x] config_data_relations_update + * - [x] config_data_relations_remove + * - [x] config_data_indices_get + * - [x] config_data_indices_add + * - [x] config_data_indices_update + * - [x] config_data_indices_remove */ describe("mcp data", async () => { let app: App; let server: ReturnType; - beforeAll(async () => { + beforeEach(async () => { app = createApp({ initialConfig: { server: { @@ -55,6 +61,7 @@ describe("mcp data", async () => { test("config_data_entities_{add,get,update,remove}", async () => { const result = await tool(server, "config_data_entities_add", { key: "test", + return_config: true, value: {}, }); expect(result.success).toBe(true); @@ -78,6 +85,7 @@ describe("mcp data", async () => { // update const result = await tool(server, "config_data_entities_update", { key: "test", + return_config: true, value: { config: { name: "Test", @@ -100,4 +108,238 @@ describe("mcp data", async () => { expect(app.toJSON().data.entities?.test).toBeUndefined(); } }); + + test("config_data_relations_{add,get,update,remove}", async () => { + // create posts and comments + await tool(server, "config_data_entities_add", { + key: "posts", + value: {}, + }); + await tool(server, "config_data_entities_add", { + key: "comments", + value: {}, + }); + + expect(Object.keys(app.toJSON().data.entities ?? {})).toEqual(["posts", "comments"]); + + // create relation + await tool(server, "config_data_relations_add", { + key: "", // doesn't matter + value: { + type: "n:1", + source: "comments", + target: "posts", + }, + }); + + const config = app.toJSON().data; + expect( + pickKeys((config.relations?.n1_comments_posts as any) ?? {}, ["type", "source", "target"]), + ).toEqual({ + type: "n:1", + source: "comments", + target: "posts", + }); + + expect(config.entities?.comments?.fields?.posts_id?.type).toBe("relation"); + + { + // info + const postsInfo = await tool(server, "data_entity_info", { + entity: "posts", + }); + expect(postsInfo.fields).toEqual(["id"]); + expect(postsInfo.relations.all.length).toBe(1); + + const commentsInfo = await tool(server, "data_entity_info", { + entity: "comments", + }); + expect(commentsInfo.fields).toEqual(["id", "posts_id"]); + expect(commentsInfo.relations.all.length).toBe(1); + } + + // update + await tool(server, "config_data_relations_update", { + key: "n1_comments_posts", + value: { + config: { + with_limit: 10, + }, + }, + }); + expect((app.toJSON().data.relations?.n1_comments_posts?.config as any)?.with_limit).toBe(10); + + // delete + await tool(server, "config_data_relations_remove", { + key: "n1_comments_posts", + }); + expect(app.toJSON().data.relations?.n1_comments_posts).toBeUndefined(); + }); + + test("config_data_indices_update", async () => { + expect(server.tools.map((t) => t.name).includes("config_data_indices_update")).toBe(false); + }); + + test("config_data_indices_{add,get,remove}", async () => { + // create posts and comments + await tool(server, "config_data_entities_add", { + key: "posts", + value: entity("posts", { + title: text(), + content: text(), + }).toJSON(), + }); + + // add index on title + await tool(server, "config_data_indices_add", { + key: "", // auto generated + value: { + entity: "posts", + fields: ["title"], + }, + }); + + expect(app.toJSON().data.indices?.idx_posts_title).toEqual({ + entity: "posts", + fields: ["title"], + unique: false, + }); + + // delete + await tool(server, "config_data_indices_remove", { + key: "idx_posts_title", + }); + expect(app.toJSON().data.indices?.idx_posts_title).toBeUndefined(); + }); + + test("data_entity_*", async () => { + // create posts and comments + await tool(server, "config_data_entities_add", { + key: "posts", + value: entity("posts", { + title: text(), + content: text(), + }).toJSON(), + }); + await tool(server, "config_data_entities_add", { + key: "comments", + value: entity("comments", { + content: text(), + }).toJSON(), + }); + + // insert a few posts + for (let i = 0; i < 10; i++) { + await tool(server, "data_entity_insert", { + entity: "posts", + json: { + title: `Post ${i}`, + }, + }); + } + // insert a few comments + for (let i = 0; i < 5; i++) { + await tool(server, "data_entity_insert", { + entity: "comments", + json: { + content: `Comment ${i}`, + }, + }); + } + + const result = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 5, + }); + expect(result.data.length).toBe(5); + expect(result.meta.items).toBe(5); + expect(result.meta.total).toBe(10); + expect(result.data[0].title).toBe("Post 0"); + + { + // count + const result = await tool(server, "data_entity_fn_count", { + entity: "posts", + }); + expect(result.count).toBe(10); + } + + { + // exists + const res = await tool(server, "data_entity_fn_exists", { + entity: "posts", + json: { + id: result.data[0].id, + }, + }); + expect(res.exists).toBe(true); + + const res2 = await tool(server, "data_entity_fn_exists", { + entity: "posts", + json: { + id: "123", + }, + }); + expect(res2.exists).toBe(false); + } + + // update + await tool(server, "data_entity_update_one", { + entity: "posts", + id: result.data[0].id, + json: { + title: "Post 0 updated", + }, + }); + const result2 = await tool(server, "data_entity_read_one", { + entity: "posts", + id: result.data[0].id, + }); + expect(result2.data.title).toBe("Post 0 updated"); + + // delete the second post + await tool(server, "data_entity_delete_one", { + entity: "posts", + id: result.data[1].id, + }); + const result3 = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 2, + }); + expect(result3.data.map((p) => p.id)).toEqual([1, 3]); + + // update many + await tool(server, "data_entity_update_many", { + entity: "posts", + update: { + title: "Post updated", + }, + where: { + title: { $isnull: 0 }, + }, + }); + const result4 = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 10, + }); + expect(result4.data.length).toBe(9); + expect(result4.data.map((p) => p.title)).toEqual( + Array.from({ length: 9 }, () => "Post updated"), + ); + + // delete many + await tool(server, "data_entity_delete_many", { + entity: "posts", + json: { + title: { $isnull: 0 }, + }, + }); + const result5 = await tool(server, "data_entity_read_many", { + entity: "posts", + limit: 10, + }); + expect(result5.data.length).toBe(0); + expect(result5.meta.items).toBe(0); + expect(result5.meta.total).toBe(0); + }); }); diff --git a/app/package.json b/app/package.json index 730e952..d4f906f 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.7.3", + "jsonv-ts": "^0.7.4", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 5b04a8f..fd11281 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -445,7 +445,15 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityUpdate), - mcpTool("data_entity_update_many"), + mcpTool("data_entity_update_many", { + inputSchema: { + param: s.object({ entity: entitiesEnum }), + json: s.object({ + update: s.object({}), + where: s.object({}), + }), + }, + }), jsc("param", s.object({ entity: entitiesEnum })), jsc( "json", @@ -521,7 +529,12 @@ export class DataController extends Controller { tags: ["data"], }), permission(DataPermissions.entityDelete), - mcpTool("data_entity_delete_many"), + mcpTool("data_entity_delete_many", { + inputSchema: { + param: s.object({ entity: entitiesEnum }), + json: s.object({}), + }, + }), jsc("param", s.object({ entity: entitiesEnum })), jsc("json", repoQuery.properties.where), async (c) => { diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index c3ff999..f08a711 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -83,7 +83,10 @@ export const dataConfigSchema = $object("config_data", { relations: $record("config_data_relations", s.anyOf(relationsSchema), { default: {}, }).optional(), - indices: $record("config_data_indices", indicesSchema, { default: {} }).optional(), + indices: $record("config_data_indices", indicesSchema, { + default: {}, + mcp: { update: false }, + }).optional(), }).strict(); export type AppDataConfig = s.Static; diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts index 4ab7bc3..b752cfd 100644 --- a/app/src/modules/mcp/$record.ts +++ b/app/src/modules/mcp/$record.ts @@ -7,7 +7,16 @@ import { type SchemaWithMcpOptions, } from "./McpSchemaHelper"; -export interface RecordToolSchemaOptions extends s.IRecordOptions, SchemaWithMcpOptions {} +type RecordToolAdditionalOptions = { + get?: boolean; + add?: boolean; + update?: boolean; + remove?: boolean; +}; + +export interface RecordToolSchemaOptions + extends s.IRecordOptions, + SchemaWithMcpOptions {} const opts = Symbol.for("bknd-mcp-record-opts"); @@ -28,7 +37,7 @@ export class RecordToolSchema< }; } - get mcp(): McpSchemaHelper { + get mcp(): McpSchemaHelper { return this[mcpSchemaSymbol]; } @@ -104,6 +113,12 @@ export class RecordToolSchema< description: "key to add", }), value: this.getNewSchema(), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), }), }, async (params, ctx: AppToolHandlerCtx) => { @@ -122,7 +137,9 @@ export class RecordToolSchema< return ctx.json({ success: true, module: module_name, - config: ctx.context.app.module[module_name as any].config, + config: params.return_config + ? ctx.context.app.module[module_name as any].config + : undefined, }); }, ); @@ -138,6 +155,12 @@ export class RecordToolSchema< description: "key to update", }), value: this.getNewSchema(s.object({})), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), }), }, async (params, ctx: AppToolHandlerCtx) => { @@ -156,7 +179,9 @@ export class RecordToolSchema< return ctx.json({ success: true, module: module_name, - config: ctx.context.app.module[module_name as any].config, + config: params.return_config + ? ctx.context.app.module[module_name as any].config + : undefined, }); }, ); @@ -171,6 +196,12 @@ export class RecordToolSchema< key: s.string({ description: "key to remove", }), + return_config: s + .boolean({ + default: false, + description: "If the new configuration should be returned", + }) + .optional(), }), }, async (params, ctx: AppToolHandlerCtx) => { @@ -189,20 +220,22 @@ export class RecordToolSchema< return ctx.json({ success: true, module: module_name, - config: ctx.context.app.module[module_name as any].config, + config: params.return_config + ? ctx.context.app.module[module_name as any].config + : undefined, }); }, ); } getTools(node: s.Node>): Tool[] { - const { tools = [] } = this.mcp.options; + const { tools = [], get = true, add = true, update = true, remove = true } = this.mcp.options; return [ - this.toolGet(node), - this.toolAdd(node), - this.toolUpdate(node), - this.toolRemove(node), + get && this.toolGet(node), + add && this.toolAdd(node), + update && this.toolUpdate(node), + remove && this.toolRemove(node), ...tools, ].filter(Boolean) as Tool[]; } diff --git a/app/src/modules/mcp/McpSchemaHelper.ts b/app/src/modules/mcp/McpSchemaHelper.ts index 2dec0d3..a8b8b08 100644 --- a/app/src/modules/mcp/McpSchemaHelper.ts +++ b/app/src/modules/mcp/McpSchemaHelper.ts @@ -21,9 +21,9 @@ export interface McpToolOptions { resources?: Resource[]; } -export interface SchemaWithMcpOptions { - mcp?: McpToolOptions; -} +export type SchemaWithMcpOptions = { + mcp?: McpToolOptions & AdditionalOptions; +}; export type AppToolContext = { app: App; @@ -35,13 +35,13 @@ export interface McpSchema extends s.Schema { getTools(node: s.Node): Tool[]; } -export class McpSchemaHelper { +export class McpSchemaHelper { cleanSchema: s.ObjectSchema; constructor( public schema: s.Schema, public name: string, - public options: McpToolOptions, + public options: McpToolOptions & AdditionalOptions, ) { this.cleanSchema = this.getCleanSchema(); } diff --git a/bun.lock b/bun.lock index 9daf9cd..438e194 100644 --- a/bun.lock +++ b/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.7.3", + "jsonv-ts": "^0.7.4", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2516,7 +2516,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.7.3", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-1P/ouF/a84Rc7NCXfSGPmkttyBFqemHE+5tZjb7hyaTs8MxmVUkuUO+d80/uu8sguzTnd3MmAuyuLAM0HQT4cA=="], + "jsonv-ts": ["jsonv-ts@0.7.4", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-SDx7Nt1kku6mAefrMffIdA9INqJnRLDJVooQOlstDmn0SvmTEHNAPifB+S14RR3f+Lep1T+WUeUdrHADrZsnYA=="], "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=="], diff --git a/docs/mcp.json b/docs/mcp.json index 0ee9e83..b97d05c 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -52,6 +52,7 @@ "properties": {} } ], + "$synthetic": true, "$target": "json" } } @@ -92,6 +93,7 @@ "properties": {} } ], + "$synthetic": true, "$target": "json" } } @@ -196,6 +198,7 @@ "properties": {} } ], + "$synthetic": true, "$target": "json" }, "select": { @@ -252,6 +255,7 @@ } } ], + "$synthetic": true, "$target": "json" } } @@ -264,7 +268,8 @@ "type": "object", "required": [ "entity", - "update" + "update", + "where" ], "properties": { "entity": { @@ -281,24 +286,9 @@ "properties": {} }, "where": { - "examples": [ - { - "attribute": { - "$eq": 1 - } - } - ], - "default": {}, - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": {} - } - ], - "$target": "json" + "type": "object", + "$target": "json", + "properties": {} } } } @@ -310,7 +300,8 @@ "type": "object", "required": [ "entity", - "id" + "id", + "json" ], "properties": { "entity": { @@ -331,6 +322,12 @@ } ], "$target": "param" + }, + "json": { + "type": "object", + "$synthetic": true, + "$target": "json", + "properties": {} } } } @@ -373,7 +370,8 @@ "inputSchema": { "type": "object", "required": [ - "entity" + "entity", + "json" ], "properties": { "entity": { @@ -385,24 +383,10 @@ "$target": "param" }, "json": { - "examples": [ - { - "attribute": { - "$eq": 1 - } - } - ], - "default": {}, - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": {} - } - ], - "$target": "json" + "type": "object", + "$synthetic": true, + "$target": "json", + "properties": {} } } } @@ -1907,6 +1891,11 @@ } } } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -1932,6 +1921,11 @@ "value": { "type": "object", "properties": {} + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -1953,6 +1947,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2193,6 +2192,11 @@ ] } ] + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2218,6 +2222,11 @@ "value": { "type": "object", "properties": {} + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2239,6 +2248,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2310,31 +2324,11 @@ "entity", "fields" ] - } - }, - "required": [ - "key", - "value" - ] - }, - "annotations": { - "destructiveHint": true, - "idempotentHint": true - } - }, - { - "name": "config_data_indices_update", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "properties": { - "key": { - "type": "string", - "description": "key to update" }, - "value": { - "type": "object", - "properties": {} + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2356,6 +2350,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2592,6 +2591,11 @@ "type", "config" ] + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2631,6 +2635,11 @@ "type", "config" ] + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2653,6 +2662,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2718,6 +2732,11 @@ "type": "boolean" } } + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2743,6 +2762,11 @@ "value": { "type": "object", "properties": {} + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ @@ -2764,6 +2788,11 @@ "key": { "type": "string", "description": "key to remove" + }, + "return_config": { + "type": "boolean", + "description": "If the new configuration should be returned", + "default": false } }, "required": [ From a6ed74d9040e886adbd6bd5c1f654d48db405ce8 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 20:57:13 +0200 Subject: [PATCH 028/199] added mcp tests for media --- app/__test__/app/mcp/mcp.media.test.ts | 92 ++++++++++++++++++++++++-- app/src/core/object/SchemaObject.ts | 1 - app/src/media/media-schema.ts | 1 + app/src/modules/mcp/$schema.ts | 20 +++++- docs/mcp.json | 12 ++-- 5 files changed, 112 insertions(+), 14 deletions(-) diff --git a/app/__test__/app/mcp/mcp.media.test.ts b/app/__test__/app/mcp/mcp.media.test.ts index c2d8669..4300ac8 100644 --- a/app/__test__/app/mcp/mcp.media.test.ts +++ b/app/__test__/app/mcp/mcp.media.test.ts @@ -1,19 +1,23 @@ -import { describe, it, expect, beforeAll } from "bun:test"; -import { type App, createApp } from "core/test/utils"; +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"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); /** - * - [ ] config_media_get - * - [ ] config_media_update - * - [ ] config_media_adapter_get - * - [ ] config_media_adapter_update + * - [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: ReturnType; - beforeAll(async () => { + beforeEach(async () => { registries.media.register("local", StorageLocalAdapter); app = createApp({ initialConfig: { @@ -35,5 +39,79 @@ describe("mcp media", async () => { }); await app.build(); server = getSystemMcp(app); + server.setLogLevel("error"); + server.onNotification((message) => { + console.dir(message, { depth: null }); + }); + }); + + const tool = createMcpToolCaller(); + + test("config_media_{get,update}", async () => { + const result = await tool(server, "config_media_get", {}); + expect(result).toEqual({ + path: "", + secrets: false, + partial: false, + value: app.toJSON().media, + }); + + // partial + expect((await tool(server, "config_media_get", { path: "adapter" })).value).toEqual({ + type: "local", + config: { + path: "./", + }, + }); + + // update + await tool(server, "config_media_update", { + value: { + storage: { + body_max_size: 1024 * 1024 * 10, + }, + }, + return_config: true, + }); + expect(app.toJSON().media.storage.body_max_size).toBe(1024 * 1024 * 10); + }); + + test("config_media_adapter_{get,update}", async () => { + const result = await tool(server, "config_media_adapter_get", {}); + expect(result).toEqual({ + secrets: false, + value: app.toJSON().media.adapter, + }); + + // update + await tool(server, "config_media_adapter_update", { + value: { + type: "local", + config: { + path: "./subdir", + }, + }, + }); + const adapter = app.toJSON().media.adapter as any; + expect(adapter.config.path).toBe("./subdir"); + expect(adapter.type).toBe("local"); + + // set to s3 + { + await tool(server, "config_media_adapter_update", { + value: { + type: "s3", + config: { + access_key: "123", + secret_access_key: "456", + url: "https://example.com/what", + }, + }, + }); + + const adapter = app.toJSON(true).media.adapter as any; + expect(adapter.type).toBe("s3"); + expect(adapter.config.url).toBe("https://example.com/what"); + } }); }); diff --git a/app/src/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index c22b811..23470a3 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -177,7 +177,6 @@ export class SchemaObject { this.throwIfRestricted(partial); - // overwrite arrays and primitives, only deep merge objects // @ts-ignore const config = set(current, path, value); diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index c506ae2..4e71d83 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -39,6 +39,7 @@ export function buildMediaSchema() { }, { default: {} }, ), + // @todo: currently cannot be updated partially using mcp adapter: $schema( "config_media_adapter", s.anyOf(Object.values(adapterSchemaObject)), diff --git a/app/src/modules/mcp/$schema.ts b/app/src/modules/mcp/$schema.ts index bc707ab..9c86d4a 100644 --- a/app/src/modules/mcp/$schema.ts +++ b/app/src/modules/mcp/$schema.ts @@ -50,12 +50,28 @@ export const $schema = < { ...mcp.getToolOptions("update"), inputSchema: s.strictObject({ - full: s.boolean({ default: false }).optional(), value: schema as any, + return_config: s.boolean({ default: false }).optional(), + secrets: s.boolean({ default: false }).optional(), }), }, async (params, ctx: AppToolHandlerCtx) => { - return ctx.json(params); + 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, + }); }, ); }; diff --git a/docs/mcp.json b/docs/mcp.json index b97d05c..064ebd4 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -2910,10 +2910,6 @@ "type": "object", "additionalProperties": false, "properties": { - "full": { - "type": "boolean", - "default": false - }, "value": { "anyOf": [ { @@ -3028,6 +3024,14 @@ ] } ] + }, + "return_config": { + "type": "boolean", + "default": false + }, + "secrets": { + "type": "boolean", + "default": false } } }, From 70f0240da506681f1289870a8e9ae97785e2eeb0 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 22:13:09 +0200 Subject: [PATCH 029/199] mcp: added auth tests, updated data tests --- app/__test__/app/mcp/mcp.auth.test.ts | 221 +++- app/__test__/app/mcp/mcp.data.test.ts | 4 +- app/package.json | 2 +- app/src/auth/auth-schema.ts | 1 + app/src/auth/authenticate/Authenticator.ts | 6 +- app/src/core/test/utils.ts | 19 +- app/src/data/data-schema.ts | 16 +- app/src/modules/ModuleHelper.ts | 10 +- app/src/modules/mcp/$record.ts | 40 +- app/src/modules/mcp/McpSchemaHelper.ts | 12 +- bun.lock | 4 +- docs/mcp.json | 1337 +++++++++++++++++--- 12 files changed, 1422 insertions(+), 250 deletions(-) diff --git a/app/__test__/app/mcp/mcp.auth.test.ts b/app/__test__/app/mcp/mcp.auth.test.ts index 1ae032e..9213b90 100644 --- a/app/__test__/app/mcp/mcp.auth.test.ts +++ b/app/__test__/app/mcp/mcp.auth.test.ts @@ -1,32 +1,40 @@ -import { describe, beforeAll } from "bun:test"; -import { type App, createApp } from "core/test/utils"; +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 { disableConsoleLog, enableConsoleLog } from "core/utils"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); /** - * - [ ] auth_me - * - [ ] auth_strategies - * - [ ] auth_user_create - * - [ ] auth_user_token - * - [ ] auth_user_password_change - * - [ ] auth_user_password_test - * - [ ] config_auth_update - * - [ ] config_auth_strategies_get - * - [ ] config_auth_strategies_add - * - [ ] config_auth_strategies_update - * - [ ] config_auth_strategies_remove - * - [ ] config_auth_roles_get - * - [ ] config_auth_roles_add - * - [ ] config_auth_roles_update - * - [ ] config_auth_roles_remove + * - [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: ReturnType; - beforeAll(async () => { + beforeEach(async () => { app = createApp({ initialConfig: { auth: { enabled: true, + jwt: { + secret: "secret", + }, }, server: { mcp: { @@ -37,5 +45,182 @@ describe("mcp auth", async () => { }); await app.build(); server = getSystemMcp(app); + server.setLogLevel("error"); + server.onNotification((message) => { + console.dir(message, { depth: null }); + }); + }); + + const tool = createMcpToolCaller(); + + test("auth_*", async () => { + const me = await tool(server, "auth_me", {}); + expect(me.user).toBeNull(); + + // strategies + const strategies = await tool(server, "auth_strategies", {}); + expect(Object.keys(strategies.strategies).length).toEqual(1); + expect(strategies.strategies.password.enabled).toBe(true); + + // create user + const user = await tool( + server, + "auth_user_create", + { + email: "test@test.com", + password: "12345678", + }, + new Headers(), + ); + expect(user.email).toBe("test@test.com"); + + // create token + const token = await tool( + server, + "auth_user_token", + { + email: "test@test.com", + }, + new Headers(), + ); + expect(token.token).toBeDefined(); + expect(token.user.email).toBe("test@test.com"); + + // me + const me2 = await tool( + server, + "auth_me", + {}, + new Request("http://localhost", { + headers: new Headers({ + Authorization: `Bearer ${token.token}`, + }), + }), + ); + expect(me2.user.email).toBe("test@test.com"); + + // change password + const changePassword = await tool( + server, + "auth_user_password_change", + { + email: "test@test.com", + password: "87654321", + }, + new Headers(), + ); + expect(changePassword.changed).toBe(true); + + // test password + const testPassword = await tool( + server, + "auth_user_password_test", + { + email: "test@test.com", + password: "87654321", + }, + new Headers(), + ); + expect(testPassword.valid).toBe(true); + }); + + test("config_auth_{get,update}", async () => { + expect(await tool(server, "config_auth_get", {})).toEqual({ + path: "", + secrets: false, + partial: false, + value: app.toJSON().auth, + }); + + // update + await tool(server, "config_auth_update", { + value: { + allow_register: false, + }, + }); + expect(app.toJSON().auth.allow_register).toBe(false); + }); + + test("config_auth_strategies_{get,add,update,remove}", async () => { + const strategies = await tool(server, "config_auth_strategies_get", { + key: "password", + }); + expect(strategies).toEqual({ + secrets: false, + module: "auth", + key: "password", + value: { + enabled: true, + type: "password", + }, + }); + + // add google oauth + const addGoogleOauth = await tool(server, "config_auth_strategies_add", { + key: "google", + value: { + type: "oauth", + enabled: true, + config: { + name: "google", + type: "oidc", + client: { + client_id: "client_id", + client_secret: "client_secret", + }, + }, + }, + return_config: true, + }); + expect(addGoogleOauth.config.google.enabled).toBe(true); + expect(app.toJSON().auth.strategies.google?.enabled).toBe(true); + + // update (disable) google oauth + await tool(server, "config_auth_strategies_update", { + key: "google", + value: { + enabled: false, + }, + }); + expect(app.toJSON().auth.strategies.google?.enabled).toBe(false); + + // remove google oauth + await tool(server, "config_auth_strategies_remove", { + key: "google", + }); + expect(app.toJSON().auth.strategies.google).toBeUndefined(); + }); + + test("config_auth_roles_{get,add,update,remove}", async () => { + // add role + const addGuestRole = await tool(server, "config_auth_roles_add", { + key: "guest", + value: { + permissions: ["read", "write"], + }, + return_config: true, + }); + expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]); + + // update role + await tool(server, "config_auth_roles_update", { + key: "guest", + value: { + permissions: ["read"], + }, + }); + expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]); + + // get role + const getGuestRole = await tool(server, "config_auth_roles_get", { + key: "guest", + }); + expect(getGuestRole.value.permissions).toEqual(["read"]); + + // remove role + await tool(server, "config_auth_roles_remove", { + key: "guest", + }); + expect(app.toJSON().auth.roles?.guest).toBeUndefined(); }); }); diff --git a/app/__test__/app/mcp/mcp.data.test.ts b/app/__test__/app/mcp/mcp.data.test.ts index 580f729..a29c764 100644 --- a/app/__test__/app/mcp/mcp.data.test.ts +++ b/app/__test__/app/mcp/mcp.data.test.ts @@ -66,7 +66,7 @@ describe("mcp data", async () => { }); expect(result.success).toBe(true); expect(result.module).toBe("data"); - expect(result.config.entities.test.type).toEqual("regular"); + expect(result.config.test?.type).toEqual("regular"); const entities = Object.keys(app.toJSON().data.entities ?? {}); expect(entities).toContain("test"); @@ -94,7 +94,7 @@ describe("mcp data", async () => { }); expect(result.success).toBe(true); expect(result.module).toBe("data"); - expect(result.config.entities.test.config?.name).toEqual("Test"); + expect(result.config.test.config?.name).toEqual("Test"); expect(app.toJSON().data.entities?.test?.config?.name).toEqual("Test"); } diff --git a/app/package.json b/app/package.json index d4f906f..9a894ef 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.7.4", + "jsonv-ts": "^0.7.5", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index 1f21a9a..4fd40a4 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -72,6 +72,7 @@ export const authConfigSchema = $object( }, s.strictObject({ type: s.string(), + enabled: s.boolean({ default: true }).optional(), config: s.object({}), }), ), diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 6c24c93..0350aba 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -385,7 +385,11 @@ export class Authenticator< headers = c.headers; } else { is_context = true; - headers = c.req.raw.headers; + try { + headers = c.req.raw.headers; + } catch (e) { + throw new Exception("Request/Headers/Context is required to resolve auth", 400); + } } let token: string | undefined; diff --git a/app/src/core/test/utils.ts b/app/src/core/test/utils.ts index 19eaa63..3eb4a39 100644 --- a/app/src/core/test/utils.ts +++ b/app/src/core/test/utils.ts @@ -13,15 +13,18 @@ export function createApp({ connection, ...config }: CreateAppConfig = {}) { } export function createMcpToolCaller() { - return async (server: ReturnType, name: string, args: any) => { - const res = await server.handle({ - jsonrpc: "2.0", - method: "tools/call", - params: { - name, - arguments: args, + return async (server: ReturnType, 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 }); diff --git a/app/src/data/data-schema.ts b/app/src/data/data-schema.ts index f08a711..f416da6 100644 --- a/app/src/data/data-schema.ts +++ b/app/src/data/data-schema.ts @@ -80,9 +80,19 @@ export const dataConfigSchema = $object("config_data", { basepath: s.string({ default: "/api/data" }).optional(), default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(), entities: $record("config_data_entities", entitiesSchema, { default: {} }).optional(), - relations: $record("config_data_relations", s.anyOf(relationsSchema), { - 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 }, diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts index 842033d..60a6dfc 100644 --- a/app/src/modules/ModuleHelper.ts +++ b/app/src/modules/ModuleHelper.ts @@ -7,7 +7,7 @@ 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 } from "bknd/utils"; +import { invariant, isPlainObject } from "bknd/utils"; export class ModuleHelper { constructor(protected ctx: Omit) {} @@ -119,12 +119,14 @@ export class ModuleHelper { c: { context: ModuleBuildContextMcpContext; raw?: unknown }, ) { invariant(c.context.app, "app is not available in mcp context"); - invariant(c.raw instanceof Request, "request is not available in mcp context"); - const auth = c.context.app.module.auth; if (!auth.enabled) return; - const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as Request); + 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( diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts index b752cfd..cbc1856 100644 --- a/app/src/modules/mcp/$record.ts +++ b/app/src/modules/mcp/$record.ts @@ -42,7 +42,7 @@ export class RecordToolSchema< } private getNewSchema(fallback: s.Schema = this.additionalProperties) { - return this[opts].new_schema ?? fallback; + return this[opts].new_schema ?? this.additionalProperties ?? fallback; } private toolGet(node: s.Node>) { @@ -122,7 +122,7 @@ export class RecordToolSchema< }), }, async (params, ctx: AppToolHandlerCtx) => { - const configs = ctx.context.app.toJSON(); + const configs = ctx.context.app.toJSON(true); const config = getPath(configs, node.instancePath); const [module_name, ...rest] = node.instancePath; @@ -134,12 +134,16 @@ export class RecordToolSchema< .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, - config: params.return_config - ? ctx.context.app.module[module_name as any].config - : undefined, + action: { + type: "add", + key: params.key, + }, + config: params.return_config ? newConfig : undefined, }); }, ); @@ -154,7 +158,7 @@ export class RecordToolSchema< key: s.string({ description: "key to update", }), - value: this.getNewSchema(s.object({})), + value: this.mcp.getCleanSchema(this.getNewSchema(s.object({}))), return_config: s .boolean({ default: false, @@ -164,7 +168,7 @@ export class RecordToolSchema< }), }, async (params, ctx: AppToolHandlerCtx) => { - const configs = ctx.context.app.toJSON(params.secrets); + const configs = ctx.context.app.toJSON(true); const config = getPath(configs, node.instancePath); const [module_name, ...rest] = node.instancePath; @@ -176,12 +180,16 @@ export class RecordToolSchema< .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, - config: params.return_config - ? ctx.context.app.module[module_name as any].config - : undefined, + action: { + type: "update", + key: params.key, + }, + config: params.return_config ? newConfig : undefined, }); }, ); @@ -205,7 +213,7 @@ export class RecordToolSchema< }), }, async (params, ctx: AppToolHandlerCtx) => { - const configs = ctx.context.app.toJSON(); + const configs = ctx.context.app.toJSON(true); const config = getPath(configs, node.instancePath); const [module_name, ...rest] = node.instancePath; @@ -217,12 +225,16 @@ export class RecordToolSchema< .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, - config: params.return_config - ? ctx.context.app.module[module_name as any].config - : undefined, + action: { + type: "remove", + key: params.key, + }, + config: params.return_config ? newConfig : undefined, }); }, ); diff --git a/app/src/modules/mcp/McpSchemaHelper.ts b/app/src/modules/mcp/McpSchemaHelper.ts index a8b8b08..686e7ff 100644 --- a/app/src/modules/mcp/McpSchemaHelper.ts +++ b/app/src/modules/mcp/McpSchemaHelper.ts @@ -43,16 +43,18 @@ export class McpSchemaHelper { public name: string, public options: McpToolOptions & AdditionalOptions, ) { - this.cleanSchema = this.getCleanSchema(); + this.cleanSchema = this.getCleanSchema(this.schema as s.ObjectSchema); } - private getCleanSchema() { + getCleanSchema(schema: s.ObjectSchema) { + if (schema.type !== "object") return schema; + const props = excludePropertyTypes( - this.schema as any, + schema as any, (i) => isPlainObject(i) && mcpSchemaSymbol in i, ); - const schema = s.strictObject(props); - return rescursiveClean(schema, { + const _schema = s.strictObject(props); + return rescursiveClean(_schema, { removeRequired: true, removeDefault: false, }) as s.ObjectSchema; diff --git a/bun.lock b/bun.lock index 438e194..6152514 100644 --- a/bun.lock +++ b/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.7.4", + "jsonv-ts": "^0.7.5", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2516,7 +2516,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.7.4", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-SDx7Nt1kku6mAefrMffIdA9INqJnRLDJVooQOlstDmn0SvmTEHNAPifB+S14RR3f+Lep1T+WUeUdrHADrZsnYA=="], + "jsonv-ts": ["jsonv-ts@0.7.5", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-/FXLINo/mbMLVFD4zjNRFfWe5D9oBsc2H9Fy/KLgmdGdhgUo9T/xbVteGWBVQSPg+P2hPdbVgaKFWgvDPk4qVw=="], "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=="], diff --git a/docs/mcp.json b/docs/mcp.json index 064ebd4..e286be7 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -1920,7 +1920,1084 @@ }, "value": { "type": "object", - "properties": {} + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "regular", + "system", + "generated" + ], + "default": "regular" + }, + "config": { + "type": "object", + "default": {}, + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "name_singular": { + "type": "string" + }, + "description": { + "type": "string" + }, + "sort_field": { + "type": "string", + "default": "id" + }, + "sort_dir": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "asc" + }, + "primary_format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ] + } + } + }, + "fields": { + "type": "object", + "default": {}, + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "title": "primary", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "primary" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "format": { + "type": "string", + "enum": [ + "integer", + "uuid" + ], + "default": "integer" + }, + "required": { + "type": "boolean", + "default": false + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + }, + { + "type": "object", + "title": "text", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "text" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "string" + }, + "minLength": { + "type": "number" + }, + "maxLength": { + "type": "number" + }, + "pattern": { + "type": "string" + }, + "html_config": { + "type": "object", + "properties": { + "element": { + "type": "string" + }, + "props": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "title": "String" + }, + { + "type": "number", + "title": "Number" + } + ] + } + } + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "number", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "number" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "multipleOf": { + "type": "number" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "boolean", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "boolean" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "date", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "date" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "date", + "datetime", + "week" + ], + "default": "date" + }, + "timezone": { + "type": "string" + }, + "min_date": { + "type": "string" + }, + "max_date": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + }, + { + "type": "object", + "title": "enum", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "enum" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": { + "type": "string" + }, + "options": { + "anyOf": [ + { + "type": "object", + "required": [ + "type", + "values" + ], + "properties": { + "type": { + "const": "strings" + }, + "values": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + { + "type": "object", + "required": [ + "type", + "values" + ], + "properties": { + "type": { + "const": "objects" + }, + "values": { + "type": "array", + "items": { + "type": "object", + "required": [ + "label", + "value" + ], + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + } + } + } + ] + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "json", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "json" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "default_value": {}, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + } + } + } + } + }, + { + "type": "object", + "title": "jsonschema", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "jsonschema" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "schema": { + "type": "object" + }, + "ui_schema": { + "type": "object" + }, + "default_from_schema": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + }, + { + "type": "object", + "title": "relation", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "relation" + }, + "config": { + "type": "object", + "additionalProperties": false, + "required": [ + "reference", + "target" + ], + "properties": { + "reference": { + "type": "string" + }, + "target": { + "type": "string" + }, + "target_field": { + "type": "string", + "default": "id" + }, + "target_field_type": { + "type": "string", + "enum": [ + "text", + "integer" + ], + "default": "integer" + }, + "on_delete": { + "type": "string", + "enum": [ + "cascade", + "set null", + "set default", + "restrict", + "no action" + ], + "default": "set null" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + }, + { + "type": "object", + "title": "media", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "const": "media" + }, + "config": { + "type": "object", + "additionalProperties": false, + "properties": { + "entity": { + "type": "string" + }, + "min_items": { + "type": "number" + }, + "max_items": { + "type": "number" + }, + "mime_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "fillable": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete" + ] + } + } + ] + }, + "hidden": { + "anyOf": [ + { + "type": "boolean", + "title": "Boolean" + }, + { + "type": "array", + "title": "Context", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "create", + "read", + "update", + "delete", + "form", + "table", + "submit" + ] + } + } + ] + }, + "virtual": { + "type": "boolean" + }, + "default_value": {} + } + } + } + } + ] + } + } + } }, "return_config": { "type": "boolean", @@ -2001,196 +3078,33 @@ "description": "key to add" }, "value": { - "anyOf": [ - { - "type": "object", - "title": "1:1", - "additionalProperties": false, - "properties": { - "type": { - "const": "1:1" - }, - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "config": { - "type": "object", - "additionalProperties": false, - "properties": { - "sourceCardinality": { - "type": "number" - }, - "with_limit": { - "type": "number", - "default": 5 - }, - "fieldConfig": { - "type": "object", - "properties": { - "label": { - "type": "string" - } - }, - "required": [ - "label" - ] - }, - "mappedBy": { - "type": "string" - }, - "inversedBy": { - "type": "string" - }, - "required": { - "type": "boolean" - } - } - } - }, - "required": [ - "type", - "source", - "target" + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "1:1", + "n:1", + "m:n", + "poly" ] }, - { - "type": "object", - "title": "n:1", - "additionalProperties": false, - "properties": { - "type": { - "const": "n:1" - }, - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "config": { - "type": "object", - "additionalProperties": false, - "properties": { - "sourceCardinality": { - "type": "number" - }, - "with_limit": { - "type": "number", - "default": 5 - }, - "fieldConfig": { - "type": "object", - "properties": { - "label": { - "type": "string" - } - }, - "required": [ - "label" - ] - }, - "mappedBy": { - "type": "string" - }, - "inversedBy": { - "type": "string" - }, - "required": { - "type": "boolean" - } - } - } - }, - "required": [ - "type", - "source", - "target" - ] + "source": { + "type": "string" }, - { - "type": "object", - "title": "m:n", - "additionalProperties": false, - "properties": { - "type": { - "const": "m:n" - }, - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "config": { - "type": "object", - "additionalProperties": false, - "properties": { - "connectionTable": { - "type": "string" - }, - "connectionTableMappedName": { - "type": "string" - }, - "mappedBy": { - "type": "string" - }, - "inversedBy": { - "type": "string" - }, - "required": { - "type": "boolean" - } - } - } - }, - "required": [ - "type", - "source", - "target" - ] + "target": { + "type": "string" }, - { + "config": { "type": "object", - "title": "poly", - "additionalProperties": false, - "properties": { - "type": { - "const": "poly" - }, - "source": { - "type": "string" - }, - "target": { - "type": "string" - }, - "config": { - "type": "object", - "additionalProperties": false, - "properties": { - "targetCardinality": { - "type": "number" - }, - "mappedBy": { - "type": "string" - }, - "inversedBy": { - "type": "string" - }, - "required": { - "type": "boolean" - } - } - } - }, - "required": [ - "type", - "source", - "target" - ] + "properties": {} } + }, + "required": [ + "type", + "source", + "target" ] }, "return_config": { @@ -2221,7 +3135,28 @@ }, "value": { "type": "object", - "properties": {} + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "1:1", + "n:1", + "m:n", + "poly" + ] + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + }, + "config": { + "type": "object", + "properties": {} + } + } }, "return_config": { "type": "boolean", @@ -2582,6 +3517,10 @@ "type": { "type": "string" }, + "enabled": { + "type": "boolean", + "default": true + }, "config": { "type": "object", "properties": {} @@ -2626,15 +3565,15 @@ "type": { "type": "string" }, + "enabled": { + "type": "boolean", + "default": true + }, "config": { "type": "object", "properties": {} } - }, - "required": [ - "type", - "config" - ] + } }, "return_config": { "type": "boolean", @@ -2761,7 +3700,21 @@ }, "value": { "type": "object", - "properties": {} + "additionalProperties": false, + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "string" + } + }, + "is_default": { + "type": "boolean" + }, + "implicit_allow": { + "type": "boolean" + } + } }, "return_config": { "type": "boolean", From 9ac5fa03c60df0b717bdbb75d6857e234ced474b Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 14 Aug 2025 10:05:15 +0200 Subject: [PATCH 030/199] optimized performance --- app/__test__/app/mcp/mcp.auth.test.ts | 6 ++--- app/__test__/app/mcp/mcp.base.test.ts | 9 ++++--- app/__test__/app/mcp/mcp.data.test.ts | 7 +++--- app/__test__/app/mcp/mcp.media.test.ts | 5 ++-- app/__test__/app/mcp/mcp.server.test.ts | 26 ++++++++++++++------- app/__test__/app/mcp/mcp.system.test.ts | 8 +++---- app/__test__/data/specs/Entity.spec.ts | 4 ---- app/__test__/debug/jsonv-resolution.test.ts | 24 +++++++++++++++++++ app/__test__/helper.ts | 2 +- app/__test__/modules/module-test-suite.ts | 3 +-- app/build.cli.ts | 23 ++++++++++++++++-- app/build.ts | 2 ++ app/package.json | 2 +- app/src/App.ts | 9 +++++-- app/src/cli/commands/mcp/mcp.ts | 3 +-- app/src/core/object/SchemaObject.ts | 2 +- app/src/core/test/utils.ts | 15 ++++++------ app/src/core/utils/file.ts | 4 ++-- app/src/core/utils/index.ts | 1 + app/src/core/utils/schema/index.ts | 1 + app/src/core/utils/schema/secret.ts | 6 ++--- app/src/index.ts | 1 + app/src/modules/Module.ts | 3 +-- app/src/modules/ModuleManager.ts | 12 ++++++++-- app/src/modules/mcp/system-mcp.ts | 7 ++---- app/src/modules/server/SystemController.ts | 9 ++++--- app/vite.dev.ts | 2 +- bun.lock | 4 ++-- 28 files changed, 134 insertions(+), 66 deletions(-) create mode 100644 app/__test__/debug/jsonv-resolution.test.ts diff --git a/app/__test__/app/mcp/mcp.auth.test.ts b/app/__test__/app/mcp/mcp.auth.test.ts index 9213b90..ea02274 100644 --- a/app/__test__/app/mcp/mcp.auth.test.ts +++ b/app/__test__/app/mcp/mcp.auth.test.ts @@ -1,7 +1,7 @@ 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 { disableConsoleLog, enableConsoleLog } from "core/utils"; +import type { McpServer } from "bknd/utils"; beforeAll(disableConsoleLog); afterAll(enableConsoleLog); @@ -26,7 +26,7 @@ afterAll(enableConsoleLog); */ describe("mcp auth", async () => { let app: App; - let server: ReturnType; + let server: McpServer; beforeEach(async () => { app = createApp({ initialConfig: { @@ -44,7 +44,7 @@ describe("mcp auth", async () => { }, }); await app.build(); - server = getSystemMcp(app); + server = app.mcp!; server.setLogLevel("error"); server.onNotification((message) => { console.dir(message, { depth: null }); diff --git a/app/__test__/app/mcp/mcp.base.test.ts b/app/__test__/app/mcp/mcp.base.test.ts index 34816e9..df8bdeb 100644 --- a/app/__test__/app/mcp/mcp.base.test.ts +++ b/app/__test__/app/mcp/mcp.base.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from "bun:test"; import { createApp } from "core/test/utils"; -import { getSystemMcp } from "modules/mcp/system-mcp"; import { registries } from "index"; import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter"; @@ -22,11 +21,15 @@ describe("mcp", () => { }, }, }, + server: { + mcp: { + enabled: true, + }, + }, }, }); await app.build(); - const server = getSystemMcp(app); - expect(server.tools.length).toBeGreaterThan(0); + expect(app.mcp?.tools.length).toBeGreaterThan(0); }); }); diff --git a/app/__test__/app/mcp/mcp.data.test.ts b/app/__test__/app/mcp/mcp.data.test.ts index a29c764..69b5106 100644 --- a/app/__test__/app/mcp/mcp.data.test.ts +++ b/app/__test__/app/mcp/mcp.data.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test"; import { type App, createApp, createMcpToolCaller } from "core/test/utils"; import { getSystemMcp } from "modules/mcp/system-mcp"; -import { pickKeys } from "bknd/utils"; +import { pickKeys, type McpServer } from "bknd/utils"; import { entity, text } from "bknd"; import { disableConsoleLog, enableConsoleLog } from "core/utils"; @@ -37,8 +37,9 @@ afterAll(enableConsoleLog); */ describe("mcp data", async () => { let app: App; - let server: ReturnType; + let server: McpServer; beforeEach(async () => { + const time = performance.now(); app = createApp({ initialConfig: { server: { @@ -49,7 +50,7 @@ describe("mcp data", async () => { }, }); await app.build(); - server = getSystemMcp(app); + server = app.mcp!; server.setLogLevel("error"); server.onNotification((message) => { console.dir(message, { depth: null }); diff --git a/app/__test__/app/mcp/mcp.media.test.ts b/app/__test__/app/mcp/mcp.media.test.ts index 4300ac8..c10e8bb 100644 --- a/app/__test__/app/mcp/mcp.media.test.ts +++ b/app/__test__/app/mcp/mcp.media.test.ts @@ -4,6 +4,7 @@ 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); @@ -16,7 +17,7 @@ afterAll(enableConsoleLog); */ describe("mcp media", async () => { let app: App; - let server: ReturnType; + let server: McpServer; beforeEach(async () => { registries.media.register("local", StorageLocalAdapter); app = createApp({ @@ -38,7 +39,7 @@ describe("mcp media", async () => { }, }); await app.build(); - server = getSystemMcp(app); + server = app.mcp!; server.setLogLevel("error"); server.onNotification((message) => { console.dir(message, { depth: null }); diff --git a/app/__test__/app/mcp/mcp.server.test.ts b/app/__test__/app/mcp/mcp.server.test.ts index 29113fd..3ada557 100644 --- a/app/__test__/app/mcp/mcp.server.test.ts +++ b/app/__test__/app/mcp/mcp.server.test.ts @@ -1,6 +1,6 @@ -import { describe, test, expect, beforeAll, mock } from "bun:test"; +import { describe, test, expect, beforeAll, mock, beforeEach, afterAll } from "bun:test"; import { type App, createApp, createMcpToolCaller } from "core/test/utils"; -import { getSystemMcp } from "modules/mcp/system-mcp"; +import type { McpServer } from "bknd/utils"; /** * - [x] config_server_get @@ -8,7 +8,7 @@ import { getSystemMcp } from "modules/mcp/system-mcp"; */ describe("mcp system", async () => { let app: App; - let server: ReturnType; + let server: McpServer; beforeAll(async () => { app = createApp({ initialConfig: { @@ -20,23 +20,33 @@ describe("mcp system", async () => { }, }); await app.build(); - server = getSystemMcp(app); + server = app.mcp!; }); const tool = createMcpToolCaller(); test("config_server_get", async () => { const result = await tool(server, "config_server_get", {}); - expect(result).toEqual({ + expect(JSON.parse(JSON.stringify(result))).toEqual({ path: "", secrets: false, partial: false, - value: app.toJSON().server, + 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 = app.toJSON().server; + const original = JSON.parse(JSON.stringify(app.toJSON().server)); const result = await tool(server, "config_server_update", { value: { cors: { @@ -46,7 +56,7 @@ describe("mcp system", async () => { return_config: true, }); - expect(result).toEqual({ + expect(JSON.parse(JSON.stringify(result))).toEqual({ success: true, module: "server", config: { diff --git a/app/__test__/app/mcp/mcp.system.test.ts b/app/__test__/app/mcp/mcp.system.test.ts index 60be89b..6b08628 100644 --- a/app/__test__/app/mcp/mcp.system.test.ts +++ b/app/__test__/app/mcp/mcp.system.test.ts @@ -1,9 +1,7 @@ import { AppEvents } from "App"; import { describe, test, expect, beforeAll, mock } from "bun:test"; import { type App, createApp, createMcpToolCaller } from "core/test/utils"; -import { getSystemMcp } from "modules/mcp/system-mcp"; -import { inspect } from "node:util"; -inspect.defaultOptions.depth = 10; +import type { McpServer } from "bknd/utils"; /** * - [x] system_config @@ -13,7 +11,7 @@ inspect.defaultOptions.depth = 10; */ describe("mcp system", async () => { let app: App; - let server: ReturnType; + let server: McpServer; beforeAll(async () => { app = createApp({ initialConfig: { @@ -25,7 +23,7 @@ describe("mcp system", async () => { }, }); await app.build(); - server = getSystemMcp(app); + server = app.mcp!; }); const tool = createMcpToolCaller(); diff --git a/app/__test__/data/specs/Entity.spec.ts b/app/__test__/data/specs/Entity.spec.ts index 064db2d..220b688 100644 --- a/app/__test__/data/specs/Entity.spec.ts +++ b/app/__test__/data/specs/Entity.spec.ts @@ -47,8 +47,4 @@ describe("[data] Entity", async () => { entity.addField(field); expect(entity.getField("new_field")).toBe(field); }); - - test.only("types", async () => { - console.log(entity.toTypes()); - }); }); diff --git a/app/__test__/debug/jsonv-resolution.test.ts b/app/__test__/debug/jsonv-resolution.test.ts new file mode 100644 index 0000000..64b60e4 --- /dev/null +++ b/app/__test__/debug/jsonv-resolution.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "bun:test"; +import * as sDirect from "jsonv-ts"; +import { s as sFromBknd } from "bknd/utils"; + +describe("jsonv-ts resolution", () => { + it("should resolve to a single instance", () => { + const sameNamespace = sDirect === (sFromBknd as unknown as typeof sDirect); + // If this fails, two instances are being loaded via different specifiers/paths + expect(sameNamespace).toBe(true); + }); + + it("should resolve specifiers to a single package path", async () => { + const base = await import.meta.resolve("jsonv-ts"); + const hono = await import.meta.resolve("jsonv-ts/hono"); + const mcp = await import.meta.resolve("jsonv-ts/mcp"); + expect(typeof base).toBe("string"); + expect(typeof hono).toBe("string"); + expect(typeof mcp).toBe("string"); + // They can be different files (subpath exports), but they should share the same package root + const pkgRoot = (p: string) => p.slice(0, p.lastIndexOf("jsonv-ts") + "jsonv-ts".length); + expect(pkgRoot(base)).toBe(pkgRoot(hono)); + expect(pkgRoot(base)).toBe(pkgRoot(mcp)); + }); +}); diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index 1760d32..2579a88 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -5,7 +5,7 @@ import { format as sqlFormat } from "sql-formatter"; import type { em as protoEm } from "../src/data/prototype"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { slugify } from "core/utils/strings"; +import { slugify } from "bknd/utils"; import { type Connection, SqliteLocalConnection } from "data/connection"; import { EntityManager } from "data/entities/EntityManager"; diff --git a/app/__test__/modules/module-test-suite.ts b/app/__test__/modules/module-test-suite.ts index 01f597e..1f19f4e 100644 --- a/app/__test__/modules/module-test-suite.ts +++ b/app/__test__/modules/module-test-suite.ts @@ -2,13 +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 { McpServer } from "bknd/utils"; +import { DebugLogger, McpServer } from "bknd/utils"; export function makeCtx(overrides?: Partial): ModuleBuildContext { const { dummyConnection } = getDummyConnection(); diff --git a/app/build.cli.ts b/app/build.cli.ts index e874813..ff20803 100644 --- a/app/build.cli.ts +++ b/app/build.cli.ts @@ -1,13 +1,32 @@ 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"], target: "node", outdir: "./dist/cli", env: "PUBLIC_*", - minify: true, + minify: false, + external: ["jsonv-ts", "jsonv-ts/*"], define: { __isDev: "0", __version: JSON.stringify(pkg.version), diff --git a/app/build.ts b/app/build.ts index 998132c..7586c66 100644 --- a/app/build.ts +++ b/app/build.ts @@ -69,6 +69,8 @@ const external = [ "@libsql/client", "bknd", /^bknd\/.*/, + "jsonv-ts", + /^jsonv-ts\/.*/, ] as const; /** diff --git a/app/package.json b/app/package.json index 9a894ef..798928a 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.7.5", + "jsonv-ts": "^0.8.0", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/App.ts b/app/src/App.ts index 7133e6d..718ab54 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -96,6 +96,7 @@ export class App program diff --git a/app/src/core/object/SchemaObject.ts b/app/src/core/object/SchemaObject.ts index 23470a3..cf40bcf 100644 --- a/app/src/core/object/SchemaObject.ts +++ b/app/src/core/object/SchemaObject.ts @@ -27,7 +27,7 @@ export class SchemaObject { ) { this._default = deepFreeze(_schema.template({}, { withOptional: true }) as any); this._value = deepFreeze( - parse(_schema, structuredClone(initial ?? {}), { + parse(_schema, initial ?? {}, { withDefaults: true, //withExtendedDefaults: true, forceParse: this.isForceParse(), diff --git a/app/src/core/test/utils.ts b/app/src/core/test/utils.ts index 3eb4a39..c7971e2 100644 --- a/app/src/core/test/utils.ts +++ b/app/src/core/test/utils.ts @@ -1,19 +1,20 @@ -import { createApp as createAppInternal, type CreateAppConfig } from "App"; -import { bunSqlite } from "adapter/bun/connection/BunSqliteConnection"; -import { Connection } from "data/connection/Connection"; -import type { getSystemMcp } from "modules/mcp/system-mcp"; +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: ReturnType, name: string, args: any, raw?: any) => { + return async (server: McpServer, name: string, args: any, raw?: any) => { const res = await server.handle( { jsonrpc: "2.0", diff --git a/app/src/core/utils/file.ts b/app/src/core/utils/file.ts index ea5eb2b..8e812cf 100644 --- a/app/src/core/utils/file.ts +++ b/app/src/core/utils/file.ts @@ -1,7 +1,7 @@ import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; -import { randomString } from "core/utils/strings"; +import { randomString } from "./strings"; import type { Context } from "hono"; -import { invariant } from "core/utils/runtime"; +import { invariant } from "./runtime"; import { $console } from "./console"; export function getContentName(request: Request): string | undefined; diff --git a/app/src/core/utils/index.ts b/app/src/core/utils/index.ts index 30321ed..163a148 100644 --- a/app/src/core/utils/index.ts +++ b/app/src/core/utils/index.ts @@ -14,3 +14,4 @@ export * from "./test"; export * from "./runtime"; export * from "./numbers"; export * from "./schema"; +export { DebugLogger } from "./DebugLogger"; diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index ebf585b..bf8b417 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -10,6 +10,7 @@ export { mcpTool, mcpResource, getMcpServer, + stdioTransport, type ToolAnnotation, type ToolHandlerCtx, } from "jsonv-ts/mcp"; diff --git a/app/src/core/utils/schema/secret.ts b/app/src/core/utils/schema/secret.ts index 7eae592..0df68d3 100644 --- a/app/src/core/utils/schema/secret.ts +++ b/app/src/core/utils/schema/secret.ts @@ -1,6 +1,6 @@ -import { StringSchema, type IStringOptions } from "jsonv-ts"; +import { s } from "bknd/utils"; -export class SecretSchema extends StringSchema {} +export class SecretSchema extends s.StringSchema {} -export const secret = (o?: O): SecretSchema & O => +export const secret = (o?: O): SecretSchema & O => new SecretSchema(o) as any; diff --git a/app/src/index.ts b/app/src/index.ts index cb9ba5d..bd6515f 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -35,6 +35,7 @@ export type { BkndConfig } from "bknd/adapter"; export * as middlewares from "modules/middlewares"; export { registries } from "modules/registries"; +export { getSystemMcp } from "modules/mcp/system-mcp"; /** * Core diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index f4b610d..f402e04 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -6,9 +6,8 @@ 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 } from "bknd/utils"; +import type { McpServer, DebugLogger } from "bknd/utils"; type PartialRec = { [P in keyof T]?: PartialRec }; diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 0201639..660902a 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -1,8 +1,16 @@ -import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } 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"; diff --git a/app/src/modules/mcp/system-mcp.ts b/app/src/modules/mcp/system-mcp.ts index f8308e0..c1ab148 100644 --- a/app/src/modules/mcp/system-mcp.ts +++ b/app/src/modules/mcp/system-mcp.ts @@ -8,12 +8,9 @@ export function getSystemMcp(app: App) { const appConfig = app.modules.configs(); const { version, ...appSchema } = app.getSchema(); - const schema = s.strictObject(appSchema); - - const nodes = [...schema.walk({ data: appConfig })].filter( - (n) => isObject(n.schema) && mcpSchemaSymbol in n.schema, - ) as s.Node[]; + const result = [...schema.walk({ maxDepth: 3 })]; + const nodes = result.filter((n) => mcpSchemaSymbol in n.schema) as s.Node[]; const tools = [ // tools from hono routes ...middlewareServer.tools, diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index f5b024c..7660232 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -16,6 +16,7 @@ import { mcpTool, mcp as mcpMiddleware, isNode, + type McpServer, } from "bknd/utils"; import type { Context, Hono } from "hono"; import { Controller } from "modules/Controller"; @@ -47,6 +48,8 @@ export type SchemaResponse = { }; export class SystemController extends Controller { + _mcpServer: McpServer | null = null; + constructor(private readonly app: App) { super(); } @@ -64,8 +67,8 @@ export class SystemController extends Controller { this.registerMcp(); - const mcpServer = getSystemMcp(app); - mcpServer.onNotification((message) => { + this._mcpServer = getSystemMcp(app); + this._mcpServer.onNotification((message) => { if (message.method === "notification/message") { const consoleMap = { emergency: "error", @@ -87,7 +90,7 @@ export class SystemController extends Controller { app.server.use( mcpMiddleware({ - server: mcpServer, + server: this._mcpServer, sessionsEnabled: true, debug: { logLevel: "debug", diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 1274d21..66036d9 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -7,7 +7,7 @@ import type { Connection } from "./src/data/connection/Connection"; import { __bknd } from "modules/ModuleManager"; import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection"; import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection"; -import { $console } from "core/utils"; +import { $console } from "bknd/utils"; import { createClient } from "@libsql/client"; registries.media.register("local", StorageLocalAdapter); diff --git a/bun.lock b/bun.lock index 6152514..d94cc32 100644 --- a/bun.lock +++ b/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.7.5", + "jsonv-ts": "^0.8.0", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2516,7 +2516,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.7.5", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-/FXLINo/mbMLVFD4zjNRFfWe5D9oBsc2H9Fy/KLgmdGdhgUo9T/xbVteGWBVQSPg+P2hPdbVgaKFWgvDPk4qVw=="], + "jsonv-ts": ["jsonv-ts@0.8.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-OS0QnkpmyqoFbK+qh7Rk+XAc+TCpWnOW1j9hJWJ1e0Lz1yGOExpa7ghokI4gUjKOwUXNq1eN7vUs+WUTzX2+gA=="], "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=="], From 63254de13a6fbb8af73af72081c9e3f01bf2e624 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 14 Aug 2025 16:49:31 +0200 Subject: [PATCH 031/199] added a simple mcp ui in tests --- app/package.json | 2 +- app/src/core/utils/schema/secret.ts | 5 +- app/src/data/api/DataController.ts | 4 +- app/src/ui/components/code/JsonViewer.tsx | 130 +++++++++---- .../ui/components/display/ErrorBoundary.tsx | 4 +- .../form/json-schema-form/AnyOfField.tsx | 35 +++- .../components/form/json-schema-form/Form.tsx | 3 +- .../components/form/json-schema-form/utils.ts | 30 +-- app/src/ui/routes/test/index.tsx | 7 +- .../routes/test/tests/json-schema-form3.tsx | 27 ++- app/src/ui/routes/test/tests/mcp/mcp-test.tsx | 33 ++++ app/src/ui/routes/test/tests/mcp/state.ts | 22 +++ app/src/ui/routes/test/tests/mcp/tools.tsx | 171 ++++++++++++++++++ app/src/ui/routes/test/tests/mcp/utils.ts | 20 ++ app/vite.dev.ts | 2 +- bun.lock | 4 +- 16 files changed, 436 insertions(+), 63 deletions(-) create mode 100644 app/src/ui/routes/test/tests/mcp/mcp-test.tsx create mode 100644 app/src/ui/routes/test/tests/mcp/state.ts create mode 100644 app/src/ui/routes/test/tests/mcp/tools.tsx create mode 100644 app/src/ui/routes/test/tests/mcp/utils.ts diff --git a/app/package.json b/app/package.json index 798928a..bea51fd 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "^0.8.0", + "jsonv-ts": "link:jsonv-ts", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/core/utils/schema/secret.ts b/app/src/core/utils/schema/secret.ts index 0df68d3..6fcdf14 100644 --- a/app/src/core/utils/schema/secret.ts +++ b/app/src/core/utils/schema/secret.ts @@ -1,6 +1,7 @@ -import { s } from "bknd/utils"; +import type { s } from "bknd/utils"; +import { StringSchema } from "jsonv-ts"; -export class SecretSchema extends s.StringSchema {} +export class SecretSchema extends StringSchema {} export const secret = (o?: O): SecretSchema & O => new SecretSchema(o) as any; diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index fd11281..d7cbc24 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -201,7 +201,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 diff --git a/app/src/ui/components/code/JsonViewer.tsx b/app/src/ui/components/code/JsonViewer.tsx index 923846b..ba2c63b 100644 --- a/app/src/ui/components/code/JsonViewer.tsx +++ b/app/src/ui/components/code/JsonViewer.tsx @@ -2,6 +2,35 @@ import { TbCopy } from "react-icons/tb"; import { JsonView } from "react-json-view-lite"; import { twMerge } from "tailwind-merge"; import { IconButton } from "../buttons/IconButton"; +import ErrorBoundary from "ui/components/display/ErrorBoundary"; +import { forwardRef, useImperativeHandle, useState } from "react"; + +export type JsonViewerProps = { + json: object | null; + title?: string; + expand?: number; + showSize?: boolean; + showCopy?: boolean; + copyIconProps?: any; + className?: string; +}; + +const style = { + basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20", + container: "ml-[-10px]", + label: "text-primary/90 font-bold font-mono mr-2", + stringValue: "text-emerald-600 dark:text-emerald-500 font-mono select-text", + numberValue: "text-sky-500 dark:text-sky-400 font-mono", + nullValue: "text-zinc-400 font-mono", + undefinedValue: "text-zinc-400 font-mono", + otherValue: "text-zinc-400 font-mono", + booleanValue: "text-orange-500 dark:text-orange-400 font-mono", + punctuation: "text-zinc-400 font-bold font-mono m-0.5", + collapsedContent: "text-zinc-400 font-mono after:content-['...']", + collapseIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['▾'] mr-1.5", + expandIcon: "text-zinc-400 font-mono font-bold text-lg after:content-['▸'] mr-1.5", + noQuotesForStringValues: false, +} as any; export const JsonViewer = ({ json, @@ -11,16 +40,8 @@ export const JsonViewer = ({ showCopy = false, copyIconProps = {}, className, -}: { - json: object; - title?: string; - expand?: number; - showSize?: boolean; - showCopy?: boolean; - copyIconProps?: any; - className?: string; -}) => { - const size = showSize ? JSON.stringify(json).length : undefined; +}: JsonViewerProps) => { + const size = showSize ? (json === null ? 0 : (JSON.stringify(json)?.length ?? 0)) : undefined; const showContext = size || title || showCopy; function onCopy() { @@ -31,9 +52,10 @@ export const JsonViewer = ({
{showContext && (
- {(title || size) && ( + {(title || size !== undefined) && (
- {title && {title}} {size && ({size} Bytes)} + {title && {title}}{" "} + {size !== undefined && ({size} Bytes)}
)} {showCopy && ( @@ -43,30 +65,66 @@ export const JsonViewer = ({ )}
)} - level < expand} - style={ - { - basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20", - container: "ml-[-10px]", - label: "text-primary/90 font-bold font-mono mr-2", - stringValue: "text-emerald-500 font-mono select-text", - numberValue: "text-sky-400 font-mono", - nullValue: "text-zinc-400 font-mono", - undefinedValue: "text-zinc-400 font-mono", - otherValue: "text-zinc-400 font-mono", - booleanValue: "text-orange-400 font-mono", - punctuation: "text-zinc-400 font-bold font-mono m-0.5", - collapsedContent: "text-zinc-400 font-mono after:content-['...']", - collapseIcon: - "text-zinc-400 font-mono font-bold text-lg after:content-['▾'] mr-1.5", - expandIcon: - "text-zinc-400 font-mono font-bold text-lg after:content-['▸'] mr-1.5", - noQuotesForStringValues: false, - } as any - } - /> + + level < expand} + style={style} + /> +
); }; + +export type JsonViewerTabsProps = Omit & { + selected?: string; + tabs: { + [key: string]: JsonViewerProps & { + enabled?: boolean; + }; + }; +}; + +export type JsonViewerTabsRef = { + setSelected: (selected: string) => void; +}; + +export const JsonViewerTabs = forwardRef( + ({ tabs: _tabs, ...defaultProps }, ref) => { + const tabs = Object.fromEntries( + Object.entries(_tabs).filter(([_, v]) => v.enabled !== false), + ); + const [selected, setSelected] = useState(defaultProps.selected ?? Object.keys(tabs)[0]); + + useImperativeHandle(ref, () => ({ + setSelected, + })); + + return ( +
+
+ {Object.keys(tabs).map((key) => ( + + ))} +
+ {/* @ts-ignore */} + +
+ ); + }, +); diff --git a/app/src/ui/components/display/ErrorBoundary.tsx b/app/src/ui/components/display/ErrorBoundary.tsx index ad9dd7d..db2f8d4 100644 --- a/app/src/ui/components/display/ErrorBoundary.tsx +++ b/app/src/ui/components/display/ErrorBoundary.tsx @@ -40,7 +40,7 @@ class ErrorBoundary extends Component { {this.props.fallback} ); } - return Error1; + return {this.state.error?.message ?? "Unknown error"}; } override render() { @@ -61,7 +61,7 @@ class ErrorBoundary extends Component { } const BaseError = ({ children }: { children: ReactNode }) => ( -
+
{children}
); diff --git a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx index 40d4598..ef93cce 100644 --- a/app/src/ui/components/form/json-schema-form/AnyOfField.tsx +++ b/app/src/ui/components/form/json-schema-form/AnyOfField.tsx @@ -5,8 +5,15 @@ import { twMerge } from "tailwind-merge"; import * as Formy from "ui/components/form/Formy"; import { useEvent } from "ui/hooks/use-event"; import { FieldComponent, Field as FormField, type FieldProps as FormFieldProps } from "./Field"; -import { FormContextOverride, useDerivedFieldContext, useFormError } from "./Form"; +import { + FormContextOverride, + useDerivedFieldContext, + useFormContext, + useFormError, + useFormValue, +} from "./Form"; import { getLabel, getMultiSchemaMatched } from "./utils"; +import { FieldWrapper } from "ui/components/form/json-schema-form/FieldWrapper"; export type AnyOfFieldRootProps = { path?: string; @@ -47,7 +54,17 @@ const Root = ({ path = "", children }: AnyOfFieldRootProps) => { const errors = useFormError(path, { strict: true }); if (!schema) return `AnyOfField(${path}): no schema ${pointer}`; const [_selected, setSelected] = useAtom(selectedAtom); - const selected = _selected !== null ? _selected : matchedIndex > -1 ? matchedIndex : null; + const { + options: { anyOfNoneSelectedMode }, + } = useFormContext(); + const selected = + _selected !== null + ? _selected + : matchedIndex > -1 + ? matchedIndex + : anyOfNoneSelectedMode === "first" + ? 0 + : null; const select = useEvent((index: number | null) => { setValue(path, index !== null ? lib.getTemplate(undefined, schemas[index]) : undefined); @@ -117,15 +134,27 @@ const Select = () => { const Field = ({ name, label, ...props }: Partial) => { const { selected, selectedSchema, path, errors } = useAnyOfContext(); if (selected === null) return null; + return (
0 && "bg-red-500/10")}> - + {/* another wrap is required for primitive schemas */} +
); }; +const AnotherField = (props: Partial) => { + const { value } = useFormValue(""); + + const inputProps = { + // @todo: check, potentially just provide value + value: ["string", "number", "boolean"].includes(typeof value) ? value : undefined, + }; + return ; +}; + export const AnyOf = { Root, Select, diff --git a/app/src/ui/components/form/json-schema-form/Form.tsx b/app/src/ui/components/form/json-schema-form/Form.tsx index 8357fc8..1a054f4 100644 --- a/app/src/ui/components/form/json-schema-form/Form.tsx +++ b/app/src/ui/components/form/json-schema-form/Form.tsx @@ -46,6 +46,7 @@ type FormState = { type FormOptions = { debug?: boolean; keepEmpty?: boolean; + anyOfNoneSelectedMode?: "none" | "first"; }; export type FormContext = { @@ -190,7 +191,7 @@ export function Form< root: "", path: "", }), - [schema, initialValues], + [schema, initialValues, options], ) as any; return ( diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts index 6f3e206..7a94cd9 100644 --- a/app/src/ui/components/form/json-schema-form/utils.ts +++ b/app/src/ui/components/form/json-schema-form/utils.ts @@ -62,20 +62,26 @@ 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); + if (parentPointer === "" || parentPointer === "#") return false; + const parentSchema = lib.getSchema({ pointer: parentPointer, data }); + const required = parentSchema?.required?.includes(pointer.split("/").pop()!); + + return !!required; + } catch (e) { + console.error("isRequired", { pointer, schema, data, e }); return false; } - - const childSchema = lib.getSchema({ pointer, data, schema }); - if (typeof childSchema === "object" && "const" in childSchema) { - return true; - } - - const parentPointer = getParentPointer(pointer); - const parentSchema = lib.getSchema({ pointer: parentPointer, data }); - const required = parentSchema?.required?.includes(pointer.split("/").pop()!); - - return !!required; } export type IsTypeType = diff --git a/app/src/ui/routes/test/index.tsx b/app/src/ui/routes/test/index.tsx index d099a13..bdc44f2 100644 --- a/app/src/ui/routes/test/index.tsx +++ b/app/src/ui/routes/test/index.tsx @@ -26,8 +26,11 @@ 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 MCPTest from "./tests/mcp/mcp-test"; +import ErrorBoundary from "ui/components/display/ErrorBoundary"; const tests = { + MCPTest, DropdownTest, Themes, ModalTest, @@ -88,7 +91,9 @@ function TestRoot({ children }) {
- {children} + + {children} + ); } diff --git a/app/src/ui/routes/test/tests/json-schema-form3.tsx b/app/src/ui/routes/test/tests/json-schema-form3.tsx index be2bfb0..401ab1f 100644 --- a/app/src/ui/routes/test/tests/json-schema-form3.tsx +++ b/app/src/ui/routes/test/tests/json-schema-form3.tsx @@ -1,6 +1,7 @@ import type { JSONSchema } from "json-schema-to-ts"; import { useBknd } from "ui/client/bknd"; import { Button } from "ui/components/buttons/Button"; +import { s } from "bknd/utils"; import { AnyOf, AnyOfField, @@ -73,7 +74,31 @@ export default function JsonSchemaForm3() { return (
-
+ {/* */} + + {/* console.log("change", data)} diff --git a/app/src/ui/routes/test/tests/mcp/mcp-test.tsx b/app/src/ui/routes/test/tests/mcp/mcp-test.tsx new file mode 100644 index 0000000..c390c21 --- /dev/null +++ b/app/src/ui/routes/test/tests/mcp/mcp-test.tsx @@ -0,0 +1,33 @@ +import * as AppShell from "ui/layouts/AppShell/AppShell"; +import { useMcpStore } from "./state"; +import * as Tools from "./tools"; + +export default function MCPTest() { + const feature = useMcpStore((state) => state.feature); + const setFeature = useMcpStore((state) => state.setFeature); + + return ( + <> + +
+ MCP UI +
+
+
+ + setFeature("tools")} /> + setFeature("resources")} + > +
+ Resources +
+
+
+ {feature === "tools" && } +
+ + ); +} diff --git a/app/src/ui/routes/test/tests/mcp/state.ts b/app/src/ui/routes/test/tests/mcp/state.ts new file mode 100644 index 0000000..47558a5 --- /dev/null +++ b/app/src/ui/routes/test/tests/mcp/state.ts @@ -0,0 +1,22 @@ +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, + }, + (set) => ({ + setTools: (tools: ToolJson[]) => set({ tools }), + setFeature: (feature: Feature) => set({ feature }), + setContent: (content: ToolJson | null) => set({ content }), + }), + ), +); diff --git a/app/src/ui/routes/test/tests/mcp/tools.tsx b/app/src/ui/routes/test/tests/mcp/tools.tsx new file mode 100644 index 0000000..67d528a --- /dev/null +++ b/app/src/ui/routes/test/tests/mcp/tools.tsx @@ -0,0 +1,171 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getClient, getTemplate } from "./utils"; +import { useMcpStore } from "./state"; +import { AppShell } from "ui/layouts/AppShell"; +import { 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"; + +export function Sidebar({ open, toggle }) { + const client = getClient(); + const tools = useMcpStore((state) => state.tools); + const setTools = useMcpStore((state) => state.setTools); + const setContent = useMcpStore((state) => state.setContent); + const content = useMcpStore((state) => state.content); + const [loading, setLoading] = useState(false); + const [query, setQuery] = useState(""); + + const handleRefresh = useCallback(async () => { + setLoading(true); + const res = await client.listTools(); + if (res) setTools(res.tools); + setLoading(false); + }, []); + + useEffect(() => { + handleRefresh(); + }, []); + + return ( + ( +
+ + {tools.length} + + +
+ )} + > +
+ setQuery(e.target.value)} + /> + +
+
+ ); +} + +export function Content() { + const content = useMcpStore((state) => state.content); + const [payload, setPayload] = useState(getTemplate(content?.inputSchema)); + const [result, setResult] = useState(null); + const client = getClient(); + const jsonViewerTabsRef = useRef(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 res = await client.callTool({ + name: content.name, + arguments: payload, + }); + if (res) { + setResult(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 ( +
+ + Call Tool + + } + > + + + Tools / + {" "} + {content?.name} + + + +
+
+

{content?.description}

+ + {hasInputSchema && ( + { + setPayload(value); + }} + /> + )} + +
+
+
+
+ ); +} diff --git a/app/src/ui/routes/test/tests/mcp/utils.ts b/app/src/ui/routes/test/tests/mcp/utils.ts new file mode 100644 index 0000000..82d3df8 --- /dev/null +++ b/app/src/ui/routes/test/tests/mcp/utils.ts @@ -0,0 +1,20 @@ +import { McpClient, type McpClientConfig } from "jsonv-ts/mcp"; +import { Draft2019 } from "json-schema-library"; + +const clients = new Map(); + +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); +} diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 66036d9..bee9219 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -7,7 +7,7 @@ import type { Connection } from "./src/data/connection/Connection"; import { __bknd } from "modules/ModuleManager"; import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection"; import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection"; -import { $console } from "bknd/utils"; +import { $console } from "core/utils/console"; import { createClient } from "@libsql/client"; registries.media.register("local", StorageLocalAdapter); diff --git a/bun.lock b/bun.lock index d94cc32..092e0c4 100644 --- a/bun.lock +++ b/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.8.0", + "jsonv-ts": "link:jsonv-ts", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2516,7 +2516,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@0.8.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-OS0QnkpmyqoFbK+qh7Rk+XAc+TCpWnOW1j9hJWJ1e0Lz1yGOExpa7ghokI4gUjKOwUXNq1eN7vUs+WUTzX2+gA=="], + "jsonv-ts": ["jsonv-ts@link:jsonv-ts", {}], "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=="], From 9e0eaf42e545d3eeb23f9ddea8e1dbe40a8eea5b Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 14 Aug 2025 16:52:22 +0200 Subject: [PATCH 032/199] fix jsonv-ts version --- app/package.json | 2 +- bun.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/package.json b/app/package.json index bea51fd..2e316f7 100644 --- a/app/package.json +++ b/app/package.json @@ -65,7 +65,7 @@ "hono": "4.8.3", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", - "jsonv-ts": "link:jsonv-ts", + "jsonv-ts": "^0.8.1", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/bun.lock b/bun.lock index 092e0c4..5de980a 100644 --- a/bun.lock +++ b/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": "link:jsonv-ts", + "jsonv-ts": "^0.8.1", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", @@ -2516,7 +2516,7 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], - "jsonv-ts": ["jsonv-ts@link:jsonv-ts", {}], + "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=="], From aafd333d120d02cc3a40b9483cbb213da2609632 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 14 Aug 2025 16:56:12 +0200 Subject: [PATCH 033/199] fix form isRequired utils and test --- app/__test__/ui/json-form.spec.ts | 4 +++- app/src/ui/components/form/json-schema-form/utils.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/__test__/ui/json-form.spec.ts b/app/__test__/ui/json-form.spec.ts index c1331f2..331d6eb 100644 --- a/app/__test__/ui/json-form.spec.ts +++ b/app/__test__/ui/json-form.spec.ts @@ -102,7 +102,9 @@ describe("json form", () => { ] satisfies [string, Exclude, boolean][]; for (const [pointer, schema, output] of examples) { - expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output); + expect(utils.isRequired(new Draft2019(schema), pointer, schema), `${pointer} `).toBe( + output, + ); } }); diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts index 7a94cd9..333bba3 100644 --- a/app/src/ui/components/form/json-schema-form/utils.ts +++ b/app/src/ui/components/form/json-schema-form/utils.ts @@ -73,7 +73,6 @@ export function isRequired(lib: Draft, pointer: string, schema: JsonSchema, data } const parentPointer = getParentPointer(pointer); - if (parentPointer === "" || parentPointer === "#") return false; const parentSchema = lib.getSchema({ pointer: parentPointer, data }); const required = parentSchema?.required?.includes(pointer.split("/").pop()!); From deb8aacca4212ef191a0438a4a3303f8b02d12d4 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 15 Aug 2025 10:12:09 +0200 Subject: [PATCH 034/199] added mcp ui as tool --- app/src/data/api/DataController.ts | 6 +- app/src/modules/mcp/$object.ts | 4 + app/src/modules/mcp/$record.ts | 4 + app/src/modules/server/AdminController.tsx | 2 +- app/src/modules/server/SystemController.ts | 6 +- app/src/ui/layouts/AppShell/AppShell.tsx | 89 ++++--- app/src/ui/layouts/AppShell/Header.tsx | 17 +- app/src/ui/main.css | 9 +- app/src/ui/routes/index.tsx | 6 + app/src/ui/routes/media/_media.root.tsx | 2 - app/src/ui/routes/test/index.tsx | 2 - app/src/ui/routes/test/tests/mcp/tools.tsx | 171 -------------- app/src/ui/routes/tools/index.tsx | 16 ++ .../routes/tools/mcp/components/mcp-icon.tsx | 15 ++ .../mcp/mcp-test.tsx => tools/mcp/mcp.tsx} | 27 ++- .../routes/{test/tests => tools}/mcp/state.ts | 9 + app/src/ui/routes/tools/mcp/tools.tsx | 217 ++++++++++++++++++ .../routes/{test/tests => tools}/mcp/utils.ts | 0 app/src/ui/store/appshell.ts | 64 +++++- 19 files changed, 445 insertions(+), 221 deletions(-) delete mode 100644 app/src/ui/routes/test/tests/mcp/tools.tsx create mode 100644 app/src/ui/routes/tools/index.tsx create mode 100644 app/src/ui/routes/tools/mcp/components/mcp-icon.tsx rename app/src/ui/routes/{test/tests/mcp/mcp-test.tsx => tools/mcp/mcp.tsx} (58%) rename app/src/ui/routes/{test/tests => tools}/mcp/state.ts (56%) create mode 100644 app/src/ui/routes/tools/mcp/tools.tsx rename app/src/ui/routes/{test/tests => tools}/mcp/utils.ts (100%) diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index d7cbc24..ba79df7 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -62,7 +62,11 @@ export class DataController extends Controller { hono.get( "/sync", permission(DataPermissions.databaseSync), - mcpTool("data_sync"), + mcpTool("data_sync", { + annotations: { + destructiveHint: true, + }, + }), describeRoute({ summary: "Sync database schema", tags: ["data"], diff --git a/app/src/modules/mcp/$object.ts b/app/src/modules/mcp/$object.ts index f52c723..a57257b 100644 --- a/app/src/modules/mcp/$object.ts +++ b/app/src/modules/mcp/$object.ts @@ -53,6 +53,10 @@ export class ObjectToolSchema< }) .optional(), }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, }, async (params, ctx: AppToolHandlerCtx) => { const configs = ctx.context.app.toJSON(params.secrets); diff --git a/app/src/modules/mcp/$record.ts b/app/src/modules/mcp/$record.ts index cbc1856..a10054a 100644 --- a/app/src/modules/mcp/$record.ts +++ b/app/src/modules/mcp/$record.ts @@ -69,6 +69,10 @@ export class RecordToolSchema< }) .optional(), }), + annotations: { + readOnlyHint: true, + destructiveHint: false, + }, }, async (params, ctx: AppToolHandlerCtx) => { const configs = ctx.context.app.toJSON(params.secrets); diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index d714098..d15eefe 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -91,7 +91,7 @@ export class AdminController extends Controller { logout: "/api/auth/logout", }; - const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"]; + const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*", "/tools/*"]; if (isDebug()) { paths.push("/test/*"); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 7660232..15914ae 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -131,7 +131,11 @@ export class SystemController extends Controller { summary: "Get the config for a module", tags: ["system"], }), - mcpTool("system_config"), // @todo: ":module" gets not removed + 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) => { diff --git a/app/src/ui/layouts/AppShell/AppShell.tsx b/app/src/ui/layouts/AppShell/AppShell.tsx index 9018e29..a161356 100644 --- a/app/src/ui/layouts/AppShell/AppShell.tsx +++ b/app/src/ui/layouts/AppShell/AppShell.tsx @@ -19,15 +19,9 @@ import { appShellStore } from "ui/store"; import { useLocation } from "wouter"; export function Root({ children }: { children: React.ReactNode }) { - const sidebarWidth = appShellStore((store) => store.sidebarWidth); return ( -
+
{children}
@@ -97,10 +91,24 @@ export function Main({ children }) { ); } -export function Sidebar({ children }) { - const open = appShellStore((store) => store.sidebarOpen); - const close = appShellStore((store) => store.closeSidebar); +export function Sidebar({ + children, + name = "default", + handle = "right", + minWidth, + maxWidth, +}: { + children: React.ReactNode; + name?: string; + handle?: "right" | "left"; + minWidth?: number; + maxWidth?: number; +}) { + const open = appShellStore((store) => store.sidebars[name]?.open); + const close = appShellStore((store) => store.closeSidebar(name)); + const width = appShellStore((store) => store.sidebars[name]?.width ?? 350); const ref = useClickOutside(close, ["mouseup", "touchend"]); //, [document.getElementById("header")]); + const sidebarRef = useRef(null!); const [location] = useLocation(); const closeHandler = () => { @@ -115,16 +123,35 @@ export function Sidebar({ children }) { return ( <> + {handle === "left" && ( + + )} - + {handle === "right" && ( + + )}