mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 12:37:20 +00:00
initialized mcp support
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
|
||||
@@ -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<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
||||
|
||||
@@ -19,6 +20,7 @@ export type ModuleBuildContext = {
|
||||
logger: DebugLogger;
|
||||
flags: (typeof Module)["ctx_flags"];
|
||||
helper: ModuleHelper;
|
||||
mcp: McpServer<{ ctx: () => ModuleBuildContext }>;
|
||||
};
|
||||
|
||||
export abstract class Module<Schema extends object = object> {
|
||||
|
||||
@@ -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<ServerEnv>;
|
||||
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 {
|
||||
|
||||
136
app/src/modules/mcp/$object.ts
Normal file
136
app/src/modules/mcp/$object.ts
Normal file
@@ -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<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<
|
||||
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 { 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<P, O>;
|
||||
}
|
||||
|
||||
private toolGet(node: s.Node<ObjectToolSchema>) {
|
||||
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<ObjectToolSchema>) {
|
||||
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<P, O>,
|
||||
}),
|
||||
},
|
||||
async (params, ctx: AppToolHandlerCtx) => {
|
||||
return ctx.json(params);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getTools(node: s.Node<ObjectToolSchema>): Tool<any, any, any>[] {
|
||||
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<ObjectToolSchemaOptions, O>,
|
||||
): ObjectToolSchema<P, O> & O => {
|
||||
return new ObjectToolSchema(name, properties, options) as any;
|
||||
};
|
||||
1
app/src/modules/mcp/index.ts
Normal file
1
app/src/modules/mcp/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./$object";
|
||||
36
app/src/modules/mcp/utils.spec.ts
Normal file
36
app/src/modules/mcp/utils.spec.ts
Normal file
@@ -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]));
|
||||
});
|
||||
});
|
||||
51
app/src/modules/mcp/utils.ts
Normal file
51
app/src/modules/mcp/utils.ts
Normal file
@@ -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<any, any>,
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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<typeof serverConfigSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user