mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
added mcp tools from routes
This commit is contained in:
@@ -64,7 +64,7 @@
|
||||
"hono": "4.8.3",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"jsonv-ts": "0.3.2",
|
||||
"jsonv-ts": "^0.5.2-rc.1",
|
||||
"kysely": "0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
@@ -101,7 +101,6 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"jotai": "^2.12.2",
|
||||
"jsdom": "^26.0.0",
|
||||
"jsonv-ts": "^0.3.2",
|
||||
"kysely-d1": "^0.3.0",
|
||||
"kysely-generic-sqlite": "^1.2.1",
|
||||
"libsql-stateless-easy": "^1.8.0",
|
||||
|
||||
@@ -5,7 +5,15 @@ import * as AuthPermissions from "auth/auth-permissions";
|
||||
import * as DataPermissions from "data/permissions";
|
||||
import type { Hono } from "hono";
|
||||
import { Controller, type ServerEnv } from "modules/Controller";
|
||||
import { describeRoute, jsc, s, parse, InvalidSchemaError, transformObject } from "bknd/utils";
|
||||
import {
|
||||
describeRoute,
|
||||
jsc,
|
||||
s,
|
||||
parse,
|
||||
InvalidSchemaError,
|
||||
transformObject,
|
||||
mcpTool,
|
||||
} from "bknd/utils";
|
||||
|
||||
export type AuthActionResponse = {
|
||||
success: boolean;
|
||||
@@ -118,6 +126,9 @@ export class AuthController extends Controller {
|
||||
summary: "Get the current user",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
mcpTool("auth_me", {
|
||||
noErrorCodes: [403],
|
||||
}),
|
||||
auth(),
|
||||
async (c) => {
|
||||
const claims = c.get("auth")?.user;
|
||||
@@ -159,6 +170,7 @@ export class AuthController extends Controller {
|
||||
summary: "Get the available authentication strategies",
|
||||
tags: ["auth"],
|
||||
}),
|
||||
mcpTool("auth_strategies"),
|
||||
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
|
||||
async (c) => {
|
||||
const { include_disabled } = c.req.valid("query");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CliCommand } from "cli/types";
|
||||
import { makeAppFromEnv } from "../run";
|
||||
import { s, mcp as mcpMiddleware, McpServer, isObject } from "bknd/utils";
|
||||
import { s, mcp as mcpMiddleware, McpServer, isObject, getMcpServer } from "bknd/utils";
|
||||
import type { McpSchema } from "modules/mcp";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { Hono } from "hono";
|
||||
@@ -19,6 +19,10 @@ async function action(options: { port: string; path: string }) {
|
||||
server: "node",
|
||||
});
|
||||
|
||||
//console.log(info(app.server));
|
||||
|
||||
const middlewareServer = getMcpServer(app.server);
|
||||
|
||||
const appConfig = app.modules.configs();
|
||||
const { version, ...appSchema } = app.getSchema();
|
||||
|
||||
@@ -27,8 +31,12 @@ async function action(options: { port: string; path: string }) {
|
||||
const nodes = [...schema.walk({ data: appConfig })].filter(
|
||||
(n) => isObject(n.schema) && mcpSchemaSymbol in n.schema,
|
||||
) as s.Node<McpSchema>[];
|
||||
const tools = [...nodes.flatMap((n) => n.schema.getTools(n)), ...app.modules.ctx().mcp.tools];
|
||||
const resources = [...app.modules.ctx().mcp.resources];
|
||||
const tools = [
|
||||
...middlewareServer.tools,
|
||||
...nodes.flatMap((n) => n.schema.getTools(n)),
|
||||
...app.modules.ctx().mcp.tools,
|
||||
];
|
||||
const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources];
|
||||
|
||||
const server = new McpServer(
|
||||
{
|
||||
@@ -43,6 +51,9 @@ async function action(options: { port: string; path: string }) {
|
||||
const hono = new Hono().use(
|
||||
mcpMiddleware({
|
||||
server,
|
||||
debug: {
|
||||
explainEndpoint: true,
|
||||
},
|
||||
endpoint: {
|
||||
path: String(options.path) as any,
|
||||
},
|
||||
|
||||
@@ -26,6 +26,20 @@ export function omitKeys<T extends object, K extends keyof T>(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function pickKeys<T extends object, K extends keyof T>(
|
||||
obj: T,
|
||||
keys_: readonly K[],
|
||||
): Pick<T, Extract<K, keyof T>> {
|
||||
const keys = new Set(keys_);
|
||||
const result = {} as Pick<T, Extract<K, keyof T>>;
|
||||
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
||||
if (keys.has(key as K)) {
|
||||
(result as any)[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as s from "jsonv-ts";
|
||||
|
||||
export { validator as jsc, type Options } from "jsonv-ts/hono";
|
||||
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
|
||||
export { describeRoute, schemaToSpec, openAPISpecs, info } from "jsonv-ts/hono";
|
||||
export {
|
||||
mcp,
|
||||
McpServer,
|
||||
Resource,
|
||||
Tool,
|
||||
mcpTool,
|
||||
mcpResource,
|
||||
getMcpServer,
|
||||
type ToolAnnotation,
|
||||
type ToolHandlerCtx,
|
||||
} from "jsonv-ts/mcp";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Handler } from "hono/types";
|
||||
import type { ModuleBuildContext } from "modules";
|
||||
import { Controller } from "modules/Controller";
|
||||
import { jsc, s, describeRoute, schemaToSpec, omitKeys } from "bknd/utils";
|
||||
import { jsc, s, describeRoute, schemaToSpec, omitKeys, pickKeys, mcpTool } from "bknd/utils";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import type { AppDataConfig } from "../data-schema";
|
||||
import type { EntityManager, EntityData } from "data/entities";
|
||||
@@ -62,6 +62,7 @@ export class DataController extends Controller {
|
||||
hono.get(
|
||||
"/sync",
|
||||
permission(DataPermissions.databaseSync),
|
||||
mcpTool("data_sync"),
|
||||
describeRoute({
|
||||
summary: "Sync database schema",
|
||||
tags: ["data"],
|
||||
@@ -165,6 +166,7 @@ export class DataController extends Controller {
|
||||
summary: "Retrieve entity info",
|
||||
tags: ["data"],
|
||||
}),
|
||||
mcpTool("data_entity_info"),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
@@ -214,6 +216,7 @@ export class DataController extends Controller {
|
||||
summary: "Count entities",
|
||||
tags: ["data"],
|
||||
}),
|
||||
mcpTool("data_entity_fn_count"),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
async (c) => {
|
||||
@@ -236,6 +239,7 @@ export class DataController extends Controller {
|
||||
summary: "Check if entity exists",
|
||||
tags: ["data"],
|
||||
}),
|
||||
mcpTool("data_entity_fn_exists"),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
async (c) => {
|
||||
@@ -268,6 +272,9 @@ export class DataController extends Controller {
|
||||
(p) => pick.includes(p.name),
|
||||
) as any),
|
||||
];
|
||||
const saveRepoQuerySchema = (pick: string[] = Object.keys(saveRepoQuery.properties)) => {
|
||||
return s.object(pickKeys(saveRepoQuery.properties, pick as any));
|
||||
};
|
||||
|
||||
hono.get(
|
||||
"/:entity",
|
||||
@@ -300,6 +307,12 @@ export class DataController extends Controller {
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
mcpTool("data_entity_read_one", {
|
||||
inputSchema: {
|
||||
param: s.object({ entity: entitiesEnum, id: idType }),
|
||||
query: saveRepoQuerySchema(["offset", "sort", "select"]),
|
||||
},
|
||||
}),
|
||||
jsc(
|
||||
"param",
|
||||
s.object({
|
||||
@@ -375,6 +388,12 @@ export class DataController extends Controller {
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityRead),
|
||||
mcpTool("data_entity_read_many", {
|
||||
inputSchema: {
|
||||
param: s.object({ entity: entitiesEnum }),
|
||||
json: fnQuery,
|
||||
},
|
||||
}),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery, { skipOpenAPI: true }),
|
||||
async (c) => {
|
||||
@@ -400,6 +419,7 @@ export class DataController extends Controller {
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityCreate),
|
||||
mcpTool("data_entity_insert"),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
|
||||
async (c) => {
|
||||
@@ -427,6 +447,7 @@ export class DataController extends Controller {
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityUpdate),
|
||||
mcpTool("data_entity_update_many"),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc(
|
||||
"json",
|
||||
@@ -458,6 +479,7 @@ export class DataController extends Controller {
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityUpdate),
|
||||
mcpTool("data_entity_update_one"),
|
||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||
jsc("json", s.object({})),
|
||||
async (c) => {
|
||||
@@ -480,6 +502,7 @@ export class DataController extends Controller {
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityDelete),
|
||||
mcpTool("data_entity_delete_one"),
|
||||
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
|
||||
async (c) => {
|
||||
const { entity, id } = c.req.valid("param");
|
||||
@@ -500,6 +523,7 @@ export class DataController extends Controller {
|
||||
tags: ["data"],
|
||||
}),
|
||||
permission(DataPermissions.entityDelete),
|
||||
mcpTool("data_entity_delete_many"),
|
||||
jsc("param", s.object({ entity: entitiesEnum })),
|
||||
jsc("json", repoQuery.properties.where),
|
||||
async (c) => {
|
||||
|
||||
@@ -26,9 +26,6 @@ describe("server/query", () => {
|
||||
expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] });
|
||||
expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] });
|
||||
expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] });
|
||||
|
||||
expect(() => parse({ select: "not allowed" })).toThrow();
|
||||
expect(() => parse({ select: "id," })).toThrow();
|
||||
});
|
||||
|
||||
test("join", () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder"
|
||||
// helpers
|
||||
const stringIdentifier = s.string({
|
||||
// allow "id", "id,title" – but not "id," or "not allowed"
|
||||
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
|
||||
//pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
|
||||
});
|
||||
const stringArray = s.anyOf(
|
||||
[
|
||||
@@ -23,7 +23,7 @@ const stringArray = s.anyOf(
|
||||
if (v.includes(",")) {
|
||||
return v.split(",");
|
||||
}
|
||||
return [v];
|
||||
return [v].filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
@@ -78,6 +78,8 @@ const where = s.anyOf([s.string(), s.object({})], {
|
||||
},
|
||||
],
|
||||
coerce: (value: unknown) => {
|
||||
if (value === undefined || value === null || value === "") return {};
|
||||
|
||||
const q = typeof value === "string" ? JSON.parse(value) : value;
|
||||
return WhereBuilder.convert(q);
|
||||
},
|
||||
|
||||
@@ -39,9 +39,7 @@ export function buildMediaSchema() {
|
||||
},
|
||||
{ default: {} },
|
||||
),
|
||||
adapter: $record("config_media_adapter", s.anyOf(Object.values(adapterSchemaObject)), {
|
||||
maxProperties: 1,
|
||||
}).optional(),
|
||||
adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(),
|
||||
},
|
||||
{
|
||||
default: {},
|
||||
|
||||
@@ -65,8 +65,7 @@ export class Controller {
|
||||
|
||||
protected getEntitiesEnum(em: EntityManager<any>): s.StringSchema {
|
||||
const entities = em.entities.map((e) => e.name);
|
||||
// @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.string({ enum: entities }) : s.string();
|
||||
}
|
||||
|
||||
registerMcp(): void {}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { s } from "../../core/utils/schema";
|
||||
describe("rescursiveOptional", () => {
|
||||
it("should make all properties optional", () => {
|
||||
const schema = s.strictObject({
|
||||
a: s.string(),
|
||||
a: s.string({ default: "a" }),
|
||||
b: s.number(),
|
||||
nested: s.strictObject({
|
||||
c: s.string().optional(),
|
||||
@@ -15,14 +15,16 @@ describe("rescursiveOptional", () => {
|
||||
});
|
||||
|
||||
//console.log(schema.toJSON());
|
||||
console.log(
|
||||
rescursiveClean(schema, {
|
||||
removeRequired: true,
|
||||
removeDefault: true,
|
||||
}).toJSON(),
|
||||
);
|
||||
/* const result = rescursiveOptional(schema);
|
||||
expect(result.properties.a.optional).toBe(true); */
|
||||
const result = rescursiveClean(schema, {
|
||||
removeRequired: true,
|
||||
removeDefault: true,
|
||||
});
|
||||
const json = result.toJSON();
|
||||
|
||||
expect(json.required).toBeUndefined();
|
||||
expect(json.properties.a.default).toBeUndefined();
|
||||
expect(json.properties.nested.required).toBeUndefined();
|
||||
expect(json.properties.nested.properties.nested2.required).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should exclude properties", () => {
|
||||
@@ -31,6 +33,7 @@ describe("rescursiveOptional", () => {
|
||||
b: s.number(),
|
||||
});
|
||||
|
||||
console.log(excludePropertyTypes(schema, [s.StringSchema]));
|
||||
const result = excludePropertyTypes(schema, (instance) => instance instanceof s.StringSchema);
|
||||
expect(Object.keys(result).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
getTimezoneOffset,
|
||||
$console,
|
||||
getRuntimeKey,
|
||||
SecretSchema,
|
||||
jsc,
|
||||
s,
|
||||
describeRoute,
|
||||
InvalidSchemaError,
|
||||
openAPISpecs,
|
||||
mcpTool,
|
||||
} from "bknd/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { Controller } from "modules/Controller";
|
||||
@@ -78,6 +78,7 @@ export class SystemController extends Controller {
|
||||
summary: "Get the config for a module",
|
||||
tags: ["system"],
|
||||
}),
|
||||
mcpTool("system_config"), // @todo: ":module" gets not removed
|
||||
jsc("param", s.object({ module: s.string({ enum: MODULE_NAMES }).optional() })),
|
||||
jsc("query", s.object({ secrets: s.boolean().optional() })),
|
||||
async (c) => {
|
||||
@@ -284,6 +285,7 @@ export class SystemController extends Controller {
|
||||
summary: "Build the app",
|
||||
tags: ["system"],
|
||||
}),
|
||||
mcpTool("system_build"),
|
||||
jsc("query", s.object({ sync: s.boolean().optional(), fetch: s.boolean().optional() })),
|
||||
async (c) => {
|
||||
const options = c.req.valid("query") as Record<string, boolean>;
|
||||
@@ -299,6 +301,7 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.get(
|
||||
"/ping",
|
||||
mcpTool("system_ping"),
|
||||
describeRoute({
|
||||
summary: "Ping the server",
|
||||
tags: ["system"],
|
||||
@@ -308,6 +311,7 @@ export class SystemController extends Controller {
|
||||
|
||||
hono.get(
|
||||
"/info",
|
||||
mcpTool("system_info"),
|
||||
describeRoute({
|
||||
summary: "Get the server info",
|
||||
tags: ["system"],
|
||||
@@ -329,19 +333,6 @@ export class SystemController extends Controller {
|
||||
},
|
||||
origin: new URL(c.req.raw.url).origin,
|
||||
plugins: Array.from(this.app.plugins.keys()),
|
||||
walk: {
|
||||
auth: [
|
||||
...c
|
||||
.get("app")
|
||||
.getSchema()
|
||||
.auth.walk({ data: c.get("app").toJSON(true).auth }),
|
||||
]
|
||||
.filter((n) => n.schema instanceof SecretSchema)
|
||||
.map((n) => ({
|
||||
...n,
|
||||
schema: n.schema.constructor.name,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
4
bun.lock
4
bun.lock
@@ -35,7 +35,7 @@
|
||||
"hono": "4.8.3",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"jsonv-ts": "0.3.2",
|
||||
"jsonv-ts": "^0.5.2-rc.1",
|
||||
"kysely": "0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
@@ -2511,7 +2511,7 @@
|
||||
|
||||
"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.2-rc.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-mGD7peeZcrr/GeeIWxYDZTSa2/LHSeP0cWIELR63WI9p+PWJRUuDOQ4pHcESbG/syyEBvuus4Nbljnlrxwi2bQ=="],
|
||||
|
||||
"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