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:
@@ -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<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 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<P, O> {
|
||||
public readonly mcp: McpToolOptions;
|
||||
private cleanSchema: s.ObjectSchema<P, O>;
|
||||
|
||||
constructor(
|
||||
public name: string,
|
||||
properties: P,
|
||||
options?: ObjectToolSchemaOptions,
|
||||
) {
|
||||
const P extends s.TProperties = s.TProperties,
|
||||
const O extends ObjectToolSchemaOptions = ObjectToolSchemaOptions,
|
||||
>
|
||||
extends s.ObjectSchema<P, O>
|
||||
implements McpSchema
|
||||
{
|
||||
constructor(name: string, properties: P, options?: ObjectToolSchemaOptions) {
|
||||
const { mcp, ...rest } = options || {};
|
||||
|
||||
super(properties, rest as any);
|
||||
this.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<P, O>;
|
||||
get mcp(): McpSchemaHelper {
|
||||
return this[mcpSchemaSymbol];
|
||||
}
|
||||
|
||||
private toolGet(node: s.Node<ObjectToolSchema>) {
|
||||
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<ObjectToolSchema>) {
|
||||
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<ObjectToolSchema>): Tool<any, any, any>[] {
|
||||
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 = <
|
||||
|
||||
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 "./$record";
|
||||
export * from "./McpSchemaHelper";
|
||||
|
||||
@@ -35,16 +35,15 @@ export function rescursiveClean(
|
||||
|
||||
export function excludePropertyTypes(
|
||||
input: s.ObjectSchema<any, any>,
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Key extends ModuleKey = ModuleKey> = {
|
||||
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()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user