From ffbb61d58acc83d5c37801a9489f6f4ddf1a726a Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 2 Aug 2025 16:33:05 +0200 Subject: [PATCH 01/27] 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 02/27] 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 03/27] 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 04/27] 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 1b02feca930160f77736b43b8fe249657aa81766 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 7 Aug 2025 08:36:12 +0200 Subject: [PATCH 05/27] 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 06/27] 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 07/27] 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 cb873381f1b878cd98f53ef7da60b08a7a3d85ec Mon Sep 17 00:00:00 2001 From: dswbx Date: Sat, 9 Aug 2025 14:14:51 +0200 Subject: [PATCH 08/27] 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 ( +
+ + {tool.name} + +

{tool.description}

+ + +
+ ); +} + +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 09/27] 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 10/27] 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 11/27] 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 12/27] 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 13/27] 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 14/27] 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 15/27] 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 bd3d2ea900dabf44870fc54edcea8e50f84b6b5e Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 12 Aug 2025 20:22:38 +0200 Subject: [PATCH 16/27] 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 17/27] 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 18/27] 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 19/27] 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 20/27] 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 21/27] 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 22/27] 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 23/27] 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" && ( + + )}