mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
added initial $record
This commit is contained in:
@@ -100,7 +100,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"jsonv-ts": "^0.5.1",
|
"jsonv-ts": "link:jsonv-ts",
|
||||||
"kysely-d1": "^0.3.0",
|
"kysely-d1": "^0.3.0",
|
||||||
"kysely-generic-sqlite": "^1.2.1",
|
"kysely-generic-sqlite": "^1.2.1",
|
||||||
"libsql-stateless-easy": "^1.8.0",
|
"libsql-stateless-easy": "^1.8.0",
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
|
|
||||||
// load system controller
|
// load system controller
|
||||||
guard.registerPermissions(Object.values(SystemPermissions));
|
guard.registerPermissions(Object.values(SystemPermissions));
|
||||||
server.route("/api/system", new SystemController(this).getController());
|
const systemController = new SystemController(this);
|
||||||
|
systemController.registerMcp();
|
||||||
|
server.route("/api/system", systemController.getController());
|
||||||
|
|
||||||
// emit built event
|
// emit built event
|
||||||
$console.log("App built");
|
$console.log("App built");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
||||||
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
import { objectTransform, s } from "bknd/utils";
|
import { objectTransform, s } from "bknd/utils";
|
||||||
import { $object } from "modules/mcp";
|
import { $object, $record } from "modules/mcp";
|
||||||
|
|
||||||
export const Strategies = {
|
export const Strategies = {
|
||||||
password: {
|
password: {
|
||||||
@@ -37,7 +37,7 @@ export type AppAuthStrategies = s.Static<typeof strategiesSchema>;
|
|||||||
export type AppAuthOAuthStrategy = s.Static<typeof STRATEGIES.oauth.schema>;
|
export type AppAuthOAuthStrategy = s.Static<typeof STRATEGIES.oauth.schema>;
|
||||||
export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth.schema>;
|
export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||||
|
|
||||||
const guardConfigSchema = $object("config_auth_guard", {
|
const guardConfigSchema = s.object({
|
||||||
enabled: s.boolean({ default: false }).optional(),
|
enabled: s.boolean({ default: false }).optional(),
|
||||||
});
|
});
|
||||||
export const guardRoleSchema = s.strictObject({
|
export const guardRoleSchema = s.strictObject({
|
||||||
@@ -55,7 +55,10 @@ export const authConfigSchema = $object(
|
|||||||
allow_register: s.boolean({ default: true }).optional(),
|
allow_register: s.boolean({ default: true }).optional(),
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
strategies: s.record(strategiesSchema, {
|
strategies: $record(
|
||||||
|
"config_auth_strategies",
|
||||||
|
strategiesSchema,
|
||||||
|
{
|
||||||
title: "Strategies",
|
title: "Strategies",
|
||||||
default: {
|
default: {
|
||||||
password: {
|
password: {
|
||||||
@@ -66,9 +69,14 @@ export const authConfigSchema = $object(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
s.strictObject({
|
||||||
|
type: s.string(),
|
||||||
|
config: s.object({}),
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
guard: guardConfigSchema.optional(),
|
guard: guardConfigSchema.optional(),
|
||||||
roles: s.record(guardRoleSchema, { default: {} }).optional(),
|
roles: $record("config_auth_roles", guardRoleSchema, { default: {} }).optional(),
|
||||||
},
|
},
|
||||||
{ title: "Authentication" },
|
{ title: "Authentication" },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { CliCommand } from "cli/types";
|
import type { CliCommand } from "cli/types";
|
||||||
import { makeAppFromEnv } from "../run";
|
import { makeAppFromEnv } from "../run";
|
||||||
import { s, mcp as mcpMiddleware, McpServer } from "bknd/utils";
|
import { s, mcp as mcpMiddleware, McpServer, isObject } from "bknd/utils";
|
||||||
import { ObjectToolSchema } from "modules/mcp";
|
import type { McpSchema } from "modules/mcp";
|
||||||
import { serve } from "@hono/node-server";
|
import { serve } from "@hono/node-server";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import type { Module } from "modules/Module";
|
import { mcpSchemaSymbol } from "modules/mcp/McpSchemaHelper";
|
||||||
|
|
||||||
export const mcp: CliCommand = (program) =>
|
export const mcp: CliCommand = (program) =>
|
||||||
program
|
program
|
||||||
@@ -25,8 +25,8 @@ async function action(options: { port: string; path: string }) {
|
|||||||
const schema = s.strictObject(appSchema);
|
const schema = s.strictObject(appSchema);
|
||||||
|
|
||||||
const nodes = [...schema.walk({ data: appConfig })].filter(
|
const nodes = [...schema.walk({ data: appConfig })].filter(
|
||||||
(n) => n.schema instanceof ObjectToolSchema,
|
(n) => isObject(n.schema) && mcpSchemaSymbol in n.schema,
|
||||||
) as s.Node<ObjectToolSchema>[];
|
) as s.Node<McpSchema>[];
|
||||||
const tools = [...nodes.flatMap((n) => n.schema.getTools(n)), ...app.modules.ctx().mcp.tools];
|
const tools = [...nodes.flatMap((n) => n.schema.getTools(n)), ...app.modules.ctx().mcp.tools];
|
||||||
const resources = [...app.modules.ctx().mcp.resources];
|
const resources = [...app.modules.ctx().mcp.resources];
|
||||||
|
|
||||||
@@ -39,43 +39,6 @@ async function action(options: { port: string; path: string }) {
|
|||||||
tools,
|
tools,
|
||||||
resources,
|
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(
|
const hono = new Hono().use(
|
||||||
mcpMiddleware({
|
mcpMiddleware({
|
||||||
@@ -91,6 +54,10 @@ async function action(options: { port: string; path: string }) {
|
|||||||
port: Number(options.port) || 3000,
|
port: Number(options.port) || 3000,
|
||||||
});
|
});
|
||||||
console.info(`Server is running on http://localhost:${options.port}${options.path}`);
|
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(
|
||||||
console.info(`📚 Resources:\n${server.resources.map((r) => `- ${r.name}`).join("\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")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,30 @@ export function objectDepth(object: object): number {
|
|||||||
return level;
|
return level;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function limitObjectDepth<T>(obj: T, maxDepth: number): T {
|
||||||
|
function _limit(current: any, depth: number): any {
|
||||||
|
if (isPlainObject(current)) {
|
||||||
|
if (depth > maxDepth) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result: any = {};
|
||||||
|
for (const key in current) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(current, key)) {
|
||||||
|
result[key] = _limit(current[key], depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
// Arrays themselves are not limited, but their object elements are
|
||||||
|
return current.map((item) => _limit(item, depth));
|
||||||
|
}
|
||||||
|
// Primitives are always returned, regardless of depth
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return _limit(obj, 1);
|
||||||
|
}
|
||||||
|
|
||||||
export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
|
export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
|
||||||
if (!obj) return obj;
|
if (!obj) return obj;
|
||||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RelationClassMap, RelationFieldClassMap } from "data/relations";
|
|||||||
import { entityConfigSchema, entityTypes } from "data/entities";
|
import { entityConfigSchema, entityTypes } from "data/entities";
|
||||||
import { primaryFieldTypes } from "./fields";
|
import { primaryFieldTypes } from "./fields";
|
||||||
import { s } from "bknd/utils";
|
import { s } from "bknd/utils";
|
||||||
|
import { $object, $record } from "modules/mcp";
|
||||||
|
|
||||||
export const FIELDS = {
|
export const FIELDS = {
|
||||||
...FieldClassMap,
|
...FieldClassMap,
|
||||||
@@ -61,12 +62,14 @@ export const indicesSchema = s.strictObject({
|
|||||||
unique: s.boolean({ default: false }).optional(),
|
unique: s.boolean({ default: false }).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dataConfigSchema = s.strictObject({
|
export const dataConfigSchema = $object("config_data", {
|
||||||
basepath: s.string({ default: "/api/data" }).optional(),
|
basepath: s.string({ default: "/api/data" }).optional(),
|
||||||
default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(),
|
default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(),
|
||||||
entities: s.record(entitiesSchema, { default: {} }).optional(),
|
entities: $record("config_data_entities", entitiesSchema, { default: {} }).optional(),
|
||||||
relations: s.record(s.anyOf(relationsSchema), { default: {} }).optional(),
|
relations: $record("config_data_relations", s.anyOf(relationsSchema), {
|
||||||
indices: s.record(indicesSchema, { default: {} }).optional(),
|
default: {},
|
||||||
|
}).optional(),
|
||||||
|
indices: $record("config_data_indices", indicesSchema, { default: {} }).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppDataConfig = s.Static<typeof dataConfigSchema>;
|
export type AppDataConfig = s.Static<typeof dataConfigSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MediaAdapters } from "media/media-registry";
|
import { MediaAdapters } from "media/media-registry";
|
||||||
import { registries } from "modules/registries";
|
import { registries } from "modules/registries";
|
||||||
import { s, objectTransform } from "bknd/utils";
|
import { s, objectTransform } from "bknd/utils";
|
||||||
import { $object } from "modules/mcp";
|
import { $object, $record } from "modules/mcp";
|
||||||
|
|
||||||
export const ADAPTERS = {
|
export const ADAPTERS = {
|
||||||
...MediaAdapters,
|
...MediaAdapters,
|
||||||
@@ -39,7 +39,9 @@ export function buildMediaSchema() {
|
|||||||
},
|
},
|
||||||
{ default: {} },
|
{ default: {} },
|
||||||
),
|
),
|
||||||
adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(),
|
adapter: $record("config_media_adapter", s.anyOf(Object.values(adapterSchemaObject)), {
|
||||||
|
maxProperties: 1,
|
||||||
|
}).optional(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: {},
|
default: {},
|
||||||
|
|||||||
@@ -1,99 +1,71 @@
|
|||||||
import { excludePropertyTypes, rescursiveClean } from "./utils";
|
import { Tool, getPath, limitObjectDepth, s } from "bknd/utils";
|
||||||
import {
|
import {
|
||||||
type Resource,
|
McpSchemaHelper,
|
||||||
Tool,
|
mcpSchemaSymbol,
|
||||||
type ToolAnnotation,
|
type AppToolHandlerCtx,
|
||||||
type ToolHandlerCtx,
|
type McpSchema,
|
||||||
autoFormatString,
|
type SchemaWithMcpOptions,
|
||||||
getPath,
|
} from "./McpSchemaHelper";
|
||||||
s,
|
|
||||||
} from "bknd/utils";
|
|
||||||
import type { App } from "App";
|
|
||||||
import type { ModuleBuildContext } from "modules";
|
|
||||||
|
|
||||||
export interface McpToolOptions {
|
export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {}
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
annotations?: ToolAnnotation;
|
|
||||||
tools?: Tool<any, any, any>[];
|
|
||||||
resources?: Resource<any, any, any, any>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObjectToolSchemaOptions extends s.IObjectOptions {
|
|
||||||
mcp?: McpToolOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AppToolContext = {
|
|
||||||
app: App;
|
|
||||||
ctx: () => ModuleBuildContext;
|
|
||||||
};
|
|
||||||
type AppToolHandlerCtx = ToolHandlerCtx<AppToolContext>;
|
|
||||||
|
|
||||||
export class ObjectToolSchema<
|
export class ObjectToolSchema<
|
||||||
const P extends s.TProperties = s.TProperties,
|
const P extends s.TProperties = s.TProperties,
|
||||||
const O extends s.IObjectOptions = s.IObjectOptions,
|
const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions,
|
||||||
> extends s.ObjectSchema<P, O> {
|
>
|
||||||
public readonly mcp: McpToolOptions;
|
extends s.ObjectSchema<P, O>
|
||||||
private cleanSchema: s.ObjectSchema<P, O>;
|
implements McpSchema
|
||||||
|
{
|
||||||
constructor(
|
constructor(name: string, properties: P, options?: ObjectToolSchemaOptions) {
|
||||||
public name: string,
|
|
||||||
properties: P,
|
|
||||||
options?: ObjectToolSchemaOptions,
|
|
||||||
) {
|
|
||||||
const { mcp, ...rest } = options || {};
|
const { mcp, ...rest } = options || {};
|
||||||
|
|
||||||
super(properties, rest as any);
|
super(properties, rest as any);
|
||||||
this.name = name;
|
this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {});
|
||||||
this.mcp = mcp || {};
|
|
||||||
this.cleanSchema = this.getCleanSchema();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMcpOptions(action: "get" | "update") {
|
get mcp(): McpSchemaHelper {
|
||||||
const { tools, resources, ...rest } = this.mcp;
|
return this[mcpSchemaSymbol];
|
||||||
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<P, O>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toolGet(node: s.Node<ObjectToolSchema>) {
|
private toolGet(node: s.Node<ObjectToolSchema>) {
|
||||||
return new Tool(
|
return new Tool(
|
||||||
[this.name, "get"].join("_"),
|
[this.mcp.name, "get"].join("_"),
|
||||||
{
|
{
|
||||||
...this.getMcpOptions("get"),
|
...this.mcp.getToolOptions("get"),
|
||||||
inputSchema: s.strictObject({
|
inputSchema: s.strictObject({
|
||||||
path: s
|
path: s
|
||||||
.string({
|
.string({
|
||||||
pattern: /^[a-zA-Z0-9_.]{0,}$/,
|
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(),
|
.optional(),
|
||||||
include_secrets: s.boolean({ default: false }).optional(),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params, ctx: AppToolHandlerCtx) => {
|
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 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({
|
return ctx.json({
|
||||||
path: params.path ?? "",
|
path: params.path ?? "",
|
||||||
|
secrets: params.secrets ?? false,
|
||||||
|
partial: !!params.depth,
|
||||||
value: value ?? null,
|
value: value ?? null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -101,11 +73,11 @@ export class ObjectToolSchema<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toolUpdate(node: s.Node<ObjectToolSchema>) {
|
private toolUpdate(node: s.Node<ObjectToolSchema>) {
|
||||||
const schema = this.cleanSchema;
|
const schema = this.mcp.cleanSchema;
|
||||||
return new Tool(
|
return new Tool(
|
||||||
[this.name, "update"].join("_"),
|
[this.mcp.name, "update"].join("_"),
|
||||||
{
|
{
|
||||||
...this.getMcpOptions("update"),
|
...this.mcp.getToolOptions("update"),
|
||||||
inputSchema: s.strictObject({
|
inputSchema: s.strictObject({
|
||||||
full: s.boolean({ default: false }).optional(),
|
full: s.boolean({ default: false }).optional(),
|
||||||
value: s
|
value: s
|
||||||
@@ -120,14 +92,9 @@ export class ObjectToolSchema<
|
|||||||
}
|
}
|
||||||
|
|
||||||
getTools(node: s.Node<ObjectToolSchema>): Tool<any, any, any>[] {
|
getTools(node: s.Node<ObjectToolSchema>): Tool<any, any, any>[] {
|
||||||
const { tools = [] } = this.mcp;
|
const { tools = [] } = this.mcp.options;
|
||||||
return [this.toolGet(node), this.toolUpdate(node), ...tools];
|
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 = <
|
export const $object = <
|
||||||
|
|||||||
190
app/src/modules/mcp/$record.ts
Normal file
190
app/src/modules/mcp/$record.ts
Normal file
@@ -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<AP, O>
|
||||||
|
implements McpSchema
|
||||||
|
{
|
||||||
|
constructor(name: string, ap: AP, options?: RecordToolSchemaOptions, new_schema?: s.Schema) {
|
||||||
|
const { mcp, ...rest } = options || {};
|
||||||
|
super(ap, rest as any);
|
||||||
|
|
||||||
|
this[mcpSchemaSymbol] = new McpSchemaHelper(this, name, mcp || {});
|
||||||
|
this[opts] = {
|
||||||
|
new_schema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get mcp(): McpSchemaHelper {
|
||||||
|
return this[mcpSchemaSymbol];
|
||||||
|
}
|
||||||
|
|
||||||
|
private toolGet(node: s.Node<RecordToolSchema<AP, O>>) {
|
||||||
|
return new Tool(
|
||||||
|
[this.mcp.name, "get"].join("_"),
|
||||||
|
{
|
||||||
|
...this.mcp.getToolOptions("get"),
|
||||||
|
inputSchema: s.strictObject({
|
||||||
|
key: s
|
||||||
|
.string({
|
||||||
|
description: "key to get",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
secrets: s
|
||||||
|
.boolean({
|
||||||
|
default: false,
|
||||||
|
description: "(optional) include secrets in the response config",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
schema: s
|
||||||
|
.boolean({
|
||||||
|
default: false,
|
||||||
|
description: "(optional) include the schema in the response",
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
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<RecordToolSchema<AP, O>>) {
|
||||||
|
return new Tool(
|
||||||
|
[this.mcp.name, "add"].join("_"),
|
||||||
|
{
|
||||||
|
...this.mcp.getToolOptions("add"),
|
||||||
|
inputSchema: s.strictObject({
|
||||||
|
key: s.string({
|
||||||
|
description: "key to add",
|
||||||
|
}),
|
||||||
|
value: this[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<RecordToolSchema<AP, O>>) {
|
||||||
|
return new Tool(
|
||||||
|
[this.mcp.name, "update"].join("_"),
|
||||||
|
{
|
||||||
|
...this.mcp.getToolOptions("update"),
|
||||||
|
inputSchema: s.strictObject({
|
||||||
|
key: s.string({
|
||||||
|
description: "key to update",
|
||||||
|
}),
|
||||||
|
value: this[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<RecordToolSchema<AP, O>>) {
|
||||||
|
return new Tool(
|
||||||
|
[this.mcp.name, "remove"].join("_"),
|
||||||
|
{
|
||||||
|
...this.mcp.getToolOptions("get"),
|
||||||
|
inputSchema: s.strictObject({
|
||||||
|
key: s.string({
|
||||||
|
description: "key to remove",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
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<RecordToolSchema<AP, O>>): Tool<any, any, any>[] {
|
||||||
|
const { tools = [] } = this.mcp.options;
|
||||||
|
|
||||||
|
return [
|
||||||
|
this.toolGet(node),
|
||||||
|
this.toolAdd(node),
|
||||||
|
this.toolUpdate(node),
|
||||||
|
this.toolRemove(node),
|
||||||
|
...tools,
|
||||||
|
].filter(Boolean) as Tool<any, any, any>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const $record = <const AP extends s.Schema, const O extends RecordToolSchemaOptions>(
|
||||||
|
name: string,
|
||||||
|
ap: AP,
|
||||||
|
options?: s.StrictOptions<RecordToolSchemaOptions, O>,
|
||||||
|
new_schema?: s.Schema,
|
||||||
|
): RecordToolSchema<AP, O> => new RecordToolSchema(name, ap, options, new_schema) as any;
|
||||||
75
app/src/modules/mcp/McpSchemaHelper.ts
Normal file
75
app/src/modules/mcp/McpSchemaHelper.ts
Normal file
@@ -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<any, any, any>[];
|
||||||
|
resources?: Resource<any, any, any, any>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaWithMcpOptions {
|
||||||
|
mcp?: McpToolOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppToolContext = {
|
||||||
|
app: App;
|
||||||
|
ctx: () => ModuleBuildContext;
|
||||||
|
};
|
||||||
|
export type AppToolHandlerCtx = ToolHandlerCtx<AppToolContext>;
|
||||||
|
|
||||||
|
export interface McpSchema extends s.Schema {
|
||||||
|
getTools(node: s.Node<any>): Tool<any, any, any>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class McpSchemaHelper {
|
||||||
|
cleanSchema: s.ObjectSchema<any, any>;
|
||||||
|
|
||||||
|
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<any, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
getToolOptions(suffix?: string) {
|
||||||
|
const { tools, resources, ...rest } = this.options;
|
||||||
|
const label = (text?: string) =>
|
||||||
|
text && [suffix && autoFormatString(suffix), text].filter(Boolean).join(" ");
|
||||||
|
return {
|
||||||
|
title: label(this.options.title ?? this.schema.title),
|
||||||
|
description: label(this.options.description ?? this.schema.description),
|
||||||
|
annotations: {
|
||||||
|
destructiveHint: true,
|
||||||
|
idempotentHint: true,
|
||||||
|
...rest.annotations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
export * from "./$object";
|
export * from "./$object";
|
||||||
|
export * from "./$record";
|
||||||
|
export * from "./McpSchemaHelper";
|
||||||
|
|||||||
@@ -35,16 +35,15 @@ export function rescursiveClean(
|
|||||||
|
|
||||||
export function excludePropertyTypes(
|
export function excludePropertyTypes(
|
||||||
input: s.ObjectSchema<any, any>,
|
input: s.ObjectSchema<any, any>,
|
||||||
props: (new (...args: any[]) => s.Schema)[],
|
props: (instance: s.Schema | unknown) => boolean,
|
||||||
): s.TProperties {
|
): s.TProperties {
|
||||||
const properties = { ...input.properties };
|
const properties = { ...input.properties };
|
||||||
|
|
||||||
return transformObject(properties, (value, key) => {
|
return transformObject(properties, (value, key) => {
|
||||||
for (const prop of props) {
|
if (props(value)) {
|
||||||
if (value instanceof prop) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return value;
|
return value;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from "modules/ModuleManager";
|
} from "modules/ModuleManager";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { getVersion } from "core/env";
|
import { getVersion } from "core/env";
|
||||||
|
import type { Module } from "modules/Module";
|
||||||
|
|
||||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||||
success: true;
|
success: true;
|
||||||
@@ -357,4 +358,46 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
return hono;
|
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()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user