diff --git a/app/package.json b/app/package.json index 8d4bba4..9d8f501 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index b039635..ee414d3 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -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"); diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts index 2b0305e..e06c8f2 100644 --- a/app/src/cli/commands/mcp/mcp.ts +++ b/app/src/cli/commands/mcp/mcp.ts @@ -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[]; - 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, }, diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 83c5797..4a5e129 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -26,6 +26,20 @@ export function omitKeys( return result; } +export function pickKeys( + obj: T, + keys_: readonly K[], +): Pick> { + const keys = new Set(keys_); + const result = {} as Pick>; + 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(obj: T): T { return Object.entries(obj).reduce((acc, [key, value]) => { try { diff --git a/app/src/core/utils/schema/index.ts b/app/src/core/utils/schema/index.ts index 19c0e1b..ebf585b 100644 --- a/app/src/core/utils/schema/index.ts +++ b/app/src/core/utils/schema/index.ts @@ -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"; diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 952ddb4..c905e5a 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -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) => { diff --git a/app/src/data/server/query.spec.ts b/app/src/data/server/query.spec.ts index 3992599..89585a3 100644 --- a/app/src/data/server/query.spec.ts +++ b/app/src/data/server/query.spec.ts @@ -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", () => { diff --git a/app/src/data/server/query.ts b/app/src/data/server/query.ts index 48e3ed2..cb4defe 100644 --- a/app/src/data/server/query.ts +++ b/app/src/data/server/query.ts @@ -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); }, diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index dc8f5be..98661c9 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -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: {}, diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index ac6808c..3b8bd1d 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -65,8 +65,7 @@ export class Controller { protected getEntitiesEnum(em: EntityManager): 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 {} diff --git a/app/src/modules/mcp/utils.spec.ts b/app/src/modules/mcp/utils.spec.ts index 8ac11f1..a947ac1 100644 --- a/app/src/modules/mcp/utils.spec.ts +++ b/app/src/modules/mcp/utils.spec.ts @@ -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); }); }); diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 8b02fee..7d15dc6 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -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; @@ -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, - })), - }, }), ); diff --git a/bun.lock b/bun.lock index f9b1786..124121e 100644 --- a/bun.lock +++ b/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=="],