added mcp tools from routes

This commit is contained in:
dswbx
2025-08-07 08:36:12 +02:00
parent 07810ff63c
commit 1b02feca93
13 changed files with 97 additions and 44 deletions

View File

@@ -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",

View File

@@ -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");

View File

@@ -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,
},

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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) => {

View File

@@ -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", () => {

View File

@@ -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);
},

View File

@@ -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: {},

View File

@@ -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 {}

View File

@@ -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);
});
});

View File

@@ -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,
})),
},
}),
);

View File

@@ -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=="],