mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 12:56:05 +00:00
initialized mcp support
This commit is contained in:
42
app/__test__/app/mcp.spec.ts
Normal file
42
app/__test__/app/mcp.spec.ts
Normal file
@@ -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<ObjectToolSchema>[];
|
||||||
|
const tools = nodes.flatMap((n) => n.schema.getTools(n));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"tools",
|
||||||
|
tools.map((t) => t.name),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.3.2",
|
"jsonv-ts": "^0.5.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,6 +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";
|
||||||
|
|
||||||
export const Strategies = {
|
export const Strategies = {
|
||||||
password: {
|
password: {
|
||||||
@@ -36,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 = s.object({
|
const guardConfigSchema = $object("config_auth_guard", {
|
||||||
enabled: s.boolean({ default: false }).optional(),
|
enabled: s.boolean({ default: false }).optional(),
|
||||||
});
|
});
|
||||||
export const guardRoleSchema = s.strictObject({
|
export const guardRoleSchema = s.strictObject({
|
||||||
@@ -45,7 +46,8 @@ export const guardRoleSchema = s.strictObject({
|
|||||||
implicit_allow: s.boolean().optional(),
|
implicit_allow: s.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const authConfigSchema = s.strictObject(
|
export const authConfigSchema = $object(
|
||||||
|
"config_auth",
|
||||||
{
|
{
|
||||||
enabled: s.boolean({ default: false }),
|
enabled: s.boolean({ default: false }),
|
||||||
basepath: s.string({ default: "/api/auth" }),
|
basepath: s.string({ default: "/api/auth" }),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { ServerEnv } from "modules/Controller";
|
|||||||
import { pick } from "lodash-es";
|
import { pick } from "lodash-es";
|
||||||
import { InvalidConditionsException } from "auth/errors";
|
import { InvalidConditionsException } from "auth/errors";
|
||||||
import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
|
import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
|
||||||
|
import { $object } from "modules/mcp";
|
||||||
import type { AuthStrategy } from "./strategies/Strategy";
|
import type { AuthStrategy } from "./strategies/Strategy";
|
||||||
|
|
||||||
type Input = any; // workaround
|
type Input = any; // workaround
|
||||||
@@ -41,8 +42,7 @@ export interface UserPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||||
export const cookieConfig = s
|
export const cookieConfig = $object("config_auth_cookie", {
|
||||||
.object({
|
|
||||||
path: s.string({ default: "/" }),
|
path: s.string({ default: "/" }),
|
||||||
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
|
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
|
||||||
secure: s.boolean({ default: true }),
|
secure: s.boolean({ default: true }),
|
||||||
@@ -59,8 +59,8 @@ export const cookieConfig = s
|
|||||||
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
|
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
|
||||||
// see auth.integration test for further details
|
// see auth.integration test for further details
|
||||||
|
|
||||||
export const jwtConfig = s
|
export const jwtConfig = $object(
|
||||||
.object(
|
"config_auth_jwt",
|
||||||
{
|
{
|
||||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||||
secret: secret({ default: "" }),
|
secret: secret({ default: "" }),
|
||||||
@@ -72,8 +72,8 @@ export const jwtConfig = s
|
|||||||
{
|
{
|
||||||
default: {},
|
default: {},
|
||||||
},
|
},
|
||||||
)
|
).strict();
|
||||||
.strict();
|
|
||||||
export const authenticatorConfig = s.object({
|
export const authenticatorConfig = s.object({
|
||||||
jwt: jwtConfig,
|
jwt: jwtConfig,
|
||||||
cookie: cookieConfig,
|
cookie: cookieConfig,
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export { user } from "./user";
|
|||||||
export { create } from "./create";
|
export { create } from "./create";
|
||||||
export { copyAssets } from "./copy-assets";
|
export { copyAssets } from "./copy-assets";
|
||||||
export { types } from "./types";
|
export { types } from "./types";
|
||||||
|
export { mcp } from "./mcp/mcp";
|
||||||
|
|||||||
97
app/src/cli/commands/mcp/mcp.ts
Normal file
97
app/src/cli/commands/mcp/mcp.ts
Normal file
@@ -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>", "port to listen on", "3000")
|
||||||
|
.option("--path <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<ObjectToolSchema>[];
|
||||||
|
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")}`);
|
||||||
|
}
|
||||||
@@ -13,18 +13,4 @@ export * from "./uuid";
|
|||||||
export * from "./test";
|
export * from "./test";
|
||||||
export * from "./runtime";
|
export * from "./runtime";
|
||||||
export * from "./numbers";
|
export * from "./numbers";
|
||||||
export {
|
export * from "./schema";
|
||||||
s,
|
|
||||||
stripMark,
|
|
||||||
mark,
|
|
||||||
stringIdentifier,
|
|
||||||
SecretSchema,
|
|
||||||
secret,
|
|
||||||
parse,
|
|
||||||
jsc,
|
|
||||||
describeRoute,
|
|
||||||
schemaToSpec,
|
|
||||||
openAPISpecs,
|
|
||||||
type ParseOptions,
|
|
||||||
InvalidSchemaError,
|
|
||||||
} from "./schema";
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { transformObject } from "core/utils";
|
import { transformObject } from "bknd/utils";
|
||||||
|
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { DataController } from "./api/DataController";
|
import { DataController } from "./api/DataController";
|
||||||
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
|
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
|
||||||
@@ -49,10 +48,9 @@ export class AppData extends Module<AppDataConfig> {
|
|||||||
this.ctx.em.addIndex(index);
|
this.ctx.em.addIndex(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.ctx.server.route(
|
const dataController = new DataController(this.ctx, this.config);
|
||||||
this.basepath,
|
dataController.registerMcp();
|
||||||
new DataController(this.ctx, this.config).getController(),
|
this.ctx.server.route(this.basepath, dataController.getController());
|
||||||
);
|
|
||||||
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
|
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
|
||||||
|
|
||||||
this.setBuilt();
|
this.setBuilt();
|
||||||
|
|||||||
@@ -516,4 +516,35 @@ export class DataController extends Controller {
|
|||||||
|
|
||||||
return hono;
|
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",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +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";
|
||||||
|
|
||||||
export const ADAPTERS = {
|
export const ADAPTERS = {
|
||||||
...MediaAdapters,
|
...MediaAdapters,
|
||||||
@@ -22,7 +23,8 @@ export function buildMediaSchema() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return s.strictObject(
|
return $object(
|
||||||
|
"config_media",
|
||||||
{
|
{
|
||||||
enabled: s.boolean({ default: false }),
|
enabled: s.boolean({ default: false }),
|
||||||
basepath: s.string({ default: "/api/media" }),
|
basepath: s.string({ default: "/api/media" }),
|
||||||
|
|||||||
@@ -68,4 +68,6 @@ export class Controller {
|
|||||||
// @todo: current workaround to allow strings (sometimes building is not fast enough to get the entities)
|
// @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.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 { SchemaObject } from "core/object/SchemaObject";
|
||||||
import type { DebugLogger } from "core/utils/DebugLogger";
|
import type { DebugLogger } from "core/utils/DebugLogger";
|
||||||
import type { Guard } from "auth/authorize/Guard";
|
import type { Guard } from "auth/authorize/Guard";
|
||||||
|
import type { McpServer } from "jsonv-ts/mcp";
|
||||||
|
|
||||||
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export type ModuleBuildContext = {
|
|||||||
logger: DebugLogger;
|
logger: DebugLogger;
|
||||||
flags: (typeof Module)["ctx_flags"];
|
flags: (typeof Module)["ctx_flags"];
|
||||||
helper: ModuleHelper;
|
helper: ModuleHelper;
|
||||||
|
mcp: McpServer<{ ctx: () => ModuleBuildContext }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export abstract class Module<Schema extends object = object> {
|
export abstract class Module<Schema extends object = object> {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { AppMedia } from "../media/AppMedia";
|
|||||||
import type { ServerEnv } from "./Controller";
|
import type { ServerEnv } from "./Controller";
|
||||||
import { Module, type ModuleBuildContext } from "./Module";
|
import { Module, type ModuleBuildContext } from "./Module";
|
||||||
import { ModuleHelper } from "./ModuleHelper";
|
import { ModuleHelper } from "./ModuleHelper";
|
||||||
|
import { McpServer, type Resource, type Tool } from "jsonv-ts/mcp";
|
||||||
|
|
||||||
export type { ModuleBuildContext };
|
export type { ModuleBuildContext };
|
||||||
|
|
||||||
@@ -144,6 +145,7 @@ export class ModuleManager {
|
|||||||
server!: Hono<ServerEnv>;
|
server!: Hono<ServerEnv>;
|
||||||
emgr!: EventManager;
|
emgr!: EventManager;
|
||||||
guard!: Guard;
|
guard!: Guard;
|
||||||
|
mcp!: ModuleBuildContext["mcp"];
|
||||||
|
|
||||||
private _version: number = 0;
|
private _version: number = 0;
|
||||||
private _built = false;
|
private _built = false;
|
||||||
@@ -271,6 +273,9 @@ export class ModuleManager {
|
|||||||
? this.em.clear()
|
? this.em.clear()
|
||||||
: new EntityManager([], this.connection, [], [], this.emgr);
|
: new EntityManager([], this.connection, [], [], this.emgr);
|
||||||
this.guard = new Guard();
|
this.guard = new Guard();
|
||||||
|
this.mcp = new McpServer(undefined as any, {
|
||||||
|
ctx: () => this.ctx(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -281,6 +286,7 @@ export class ModuleManager {
|
|||||||
guard: this.guard,
|
guard: this.guard,
|
||||||
flags: Module.ctx_flags,
|
flags: Module.ctx_flags,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
|
mcp: this.mcp,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -702,7 +708,7 @@ export class ModuleManager {
|
|||||||
return {
|
return {
|
||||||
version: this.version(),
|
version: this.version(),
|
||||||
...schemas,
|
...schemas,
|
||||||
};
|
} as { version: number } & ModuleSchemas;
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean): { version: number } & ModuleConfigs {
|
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,13 +1,16 @@
|
|||||||
import { Exception } from "core/errors";
|
import { Exception } from "core/errors";
|
||||||
import { isDebug } from "core/env";
|
import { isDebug } from "core/env";
|
||||||
import { $console, s } from "bknd/utils";
|
import { $console, s } from "bknd/utils";
|
||||||
|
import { $object } from "modules/mcp";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { AuthException } from "auth/errors";
|
import { AuthException } from "auth/errors";
|
||||||
|
|
||||||
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const;
|
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"] as const;
|
||||||
|
|
||||||
export const serverConfigSchema = s.strictObject({
|
export const serverConfigSchema = $object(
|
||||||
|
"config_server",
|
||||||
|
{
|
||||||
cors: s.strictObject({
|
cors: s.strictObject({
|
||||||
origin: s.string({ default: "*" }),
|
origin: s.string({ default: "*" }),
|
||||||
allow_methods: s.array(s.string({ enum: serverMethods }), {
|
allow_methods: s.array(s.string({ enum: serverMethods }), {
|
||||||
@@ -19,7 +22,11 @@ export const serverConfigSchema = s.strictObject({
|
|||||||
}),
|
}),
|
||||||
allow_credentials: s.boolean({ default: true }),
|
allow_credentials: s.boolean({ default: true }),
|
||||||
}),
|
}),
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
description: "Server configuration",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export type AppServerConfig = s.Static<typeof serverConfigSchema>;
|
export type AppServerConfig = s.Static<typeof serverConfigSchema>;
|
||||||
|
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "bknd",
|
"name": "bknd",
|
||||||
"version": "0.16.0-rc.0",
|
"version": "0.16.0",
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^4.1.1",
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
@@ -71,7 +71,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.3.2",
|
"jsonv-ts": "^0.5.1",
|
||||||
"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",
|
||||||
@@ -2511,7 +2511,7 @@
|
|||||||
|
|
||||||
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user