From 3e2938f77d80a29581d403e87b27a05c446935bb Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 5 Aug 2025 13:20:00 +0200 Subject: [PATCH] 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()), + }, + ); + } }