From 42db5f55c7ddc70fddd0774fa0a50b31d5bfd1f3 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 7 Aug 2025 11:33:46 +0200 Subject: [PATCH] added authentication, $schema, fixed media adapter mcp --- app/package.json | 2 +- app/src/auth/AppAuth.ts | 29 +++++++- app/src/auth/api/AuthController.ts | 109 ++++++++++++++++++++++++++++- app/src/cli/commands/mcp/mcp.ts | 29 ++++++-- app/src/cli/commands/user.ts | 28 ++------ app/src/media/media-schema.ts | 7 +- app/src/modules/mcp/$schema.ts | 72 +++++++++++++++++++ app/src/modules/mcp/index.ts | 1 + bun.lock | 4 +- 9 files changed, 247 insertions(+), 34 deletions(-) create mode 100644 app/src/modules/mcp/$schema.ts diff --git a/app/package.json b/app/package.json index 9d8f501..8c7f6ab 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.5.2-rc.1", + "jsonv-ts": "0.6.0", "kysely": "0.27.6", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 8ee5423..5f91819 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,4 +1,4 @@ -import type { DB } from "bknd"; +import type { DB, PrimaryFieldType } from "bknd"; import * as AuthPermissions from "auth/auth-permissions"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; @@ -87,6 +87,7 @@ export class AppAuth extends Module { super.setBuilt(); this._controller = new AuthController(this); + this._controller.registerMcp(); this.ctx.server.route(this.config.basepath, this._controller.getController()); this.ctx.guard.registerPermissions(AuthPermissions); } @@ -176,6 +177,32 @@ export class AppAuth extends Module { return created; } + async changePassword(userId: PrimaryFieldType, newPassword: string) { + const users_entity = this.config.entity_name as "users"; + const { data: user } = await this.em.repository(users_entity).findId(userId); + if (!user) { + throw new Error("User not found"); + } else if (user.strategy !== "password") { + throw new Error("User is not using password strategy"); + } + + const togglePw = (visible: boolean) => { + const field = this.em.entity(users_entity).field("strategy_value")!; + + field.config.hidden = !visible; + field.config.fillable = visible; + }; + + const pw = this.authenticator.strategy("password" as const) as PasswordStrategy; + togglePw(true); + await this.em.mutator(users_entity).updateOne(user.id, { + strategy_value: await pw.hash(newPassword), + }); + togglePw(false); + + return true; + } + override toJSON(secrets?: boolean): AppAuthSchema { if (!this.config.enabled) { return this.configDefault; diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index ee414d3..f704c01 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -1,4 +1,4 @@ -import type { SafeUser } from "bknd"; +import type { DB, SafeUser } from "bknd"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { AppAuth } from "auth/AppAuth"; import * as AuthPermissions from "auth/auth-permissions"; @@ -14,6 +14,7 @@ import { transformObject, mcpTool, } from "bknd/utils"; +import type { PasswordStrategy } from "auth/authenticate/strategies"; export type AuthActionResponse = { success: boolean; @@ -200,4 +201,110 @@ export class AuthController extends Controller { return hono; } + + override registerMcp(): void { + const { mcp } = this.auth.ctx; + + const getUser = async (params: { id?: string | number; email?: string }) => { + let user: DB["users"] | undefined = undefined; + if (params.id) { + const { data } = await this.userRepo.findId(params.id); + user = data; + } else if (params.email) { + const { data } = await this.userRepo.findOne({ email: params.email }); + user = data; + } + if (!user) { + throw new Error("User not found"); + } + return user; + }; + + mcp.tool( + // @todo: needs permission + "auth_user_create", + { + description: "Create a new user", + inputSchema: s.object({ + email: s.string({ format: "email" }), + password: s.string({ minLength: 8 }), + role: s + .string({ + enum: Object.keys(this.auth.config.roles ?? {}), + }) + .optional(), + }), + }, + async (params, c) => { + return c.json(await this.auth.createUser(params)); + }, + ); + + mcp.tool( + // @todo: needs permission + "auth_user_token", + { + description: "Get a user token", + inputSchema: s.object({ + id: s.anyOf([s.string(), s.number()]).optional(), + email: s.string({ format: "email" }).optional(), + }), + }, + async (params, c) => { + const user = await getUser(params); + return c.json({ user, token: await this.auth.authenticator.jwt(user) }); + }, + ); + + mcp.tool( + // @todo: needs permission + "auth_user_password_change", + { + description: "Change a user's password", + inputSchema: s.object({ + id: s.anyOf([s.string(), s.number()]).optional(), + email: s.string({ format: "email" }).optional(), + password: s.string({ minLength: 8 }), + }), + }, + async (params, c) => { + const user = await getUser(params); + if (!(await this.auth.changePassword(user.id, params.password))) { + throw new Error("Failed to change password"); + } + return c.json({ changed: true }); + }, + ); + + mcp.tool( + // @todo: needs permission + "auth_user_password_test", + { + description: "Test a user's password", + inputSchema: s.object({ + email: s.string({ format: "email" }), + password: s.string({ minLength: 8 }), + }), + }, + async (params, c) => { + const pw = this.auth.authenticator.strategy("password") as PasswordStrategy; + const controller = pw.getController(this.auth.authenticator); + + const res = await controller.request( + new Request("https://localhost/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: params.email, + password: params.password, + }), + }), + ); + + return c.json({ valid: res.ok }); + }, + ); + } } diff --git a/app/src/cli/commands/mcp/mcp.ts b/app/src/cli/commands/mcp/mcp.ts index e06c8f2..87f1a76 100644 --- a/app/src/cli/commands/mcp/mcp.ts +++ b/app/src/cli/commands/mcp/mcp.ts @@ -5,6 +5,7 @@ import type { McpSchema } from "modules/mcp"; import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { mcpSchemaSymbol } from "modules/mcp/McpSchemaHelper"; +import { getVersion } from "cli/utils/sys"; export const mcp: CliCommand = (program) => program @@ -12,15 +13,24 @@ export const mcp: CliCommand = (program) => .description("mcp server") .option("--port ", "port to listen on", "3000") .option("--path ", "path to listen on", "/mcp") + .option( + "--token ", + "token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable", + ) + .option("--log-level ", "log level") .action(action); -async function action(options: { port: string; path: string }) { +async function action(options: { + port?: string; + path?: string; + token?: string; + logLevel?: string; +}) { const app = await makeAppFromEnv({ server: "node", }); - //console.log(info(app.server)); - + const token = options.token || process.env.BEARER_TOKEN; const middlewareServer = getMcpServer(app.server); const appConfig = app.modules.configs(); @@ -33,25 +43,34 @@ async function action(options: { port: string; path: string }) { ) as s.Node[]; const tools = [ ...middlewareServer.tools, - ...nodes.flatMap((n) => n.schema.getTools(n)), ...app.modules.ctx().mcp.tools, + ...nodes.flatMap((n) => n.schema.getTools(n)), ]; const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources]; const server = new McpServer( { name: "bknd", - version: "0.0.1", + version: await getVersion(), }, { app, ctx: () => app.modules.ctx() }, tools, resources, ); + if (token) { + server.setAuthentication({ + type: "bearer", + token, + }); + } + const hono = new Hono().use( mcpMiddleware({ server, + sessionsEnabled: true, debug: { + logLevel: options.logLevel as any, explainEndpoint: true, }, endpoint: { diff --git a/app/src/cli/commands/user.ts b/app/src/cli/commands/user.ts index 4f4db7c..811f710 100644 --- a/app/src/cli/commands/user.ts +++ b/app/src/cli/commands/user.ts @@ -85,9 +85,6 @@ async function create(app: App, options: any) { async function update(app: App, options: any) { const config = app.module.auth.toJSON(true); - const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy; - const users_entity = config.entity_name as "users"; - const em = app.modules.ctx().em; const email = (await $text({ message: "Which user? Enter email", @@ -100,7 +97,10 @@ async function update(app: App, options: any) { })) as string; if ($isCancel(email)) process.exit(1); - const { data: user } = await em.repository(users_entity).findOne({ email }); + const { data: user } = await app.modules + .ctx() + .em.repository(config.entity_name as "users") + .findOne({ email }); if (!user) { $log.error("User not found"); process.exit(1); @@ -118,26 +118,10 @@ async function update(app: App, options: any) { }); if ($isCancel(password)) process.exit(1); - try { - function togglePw(visible: boolean) { - const field = em.entity(users_entity).field("strategy_value")!; - - field.config.hidden = !visible; - field.config.fillable = visible; - } - togglePw(true); - await app.modules - .ctx() - .em.mutator(users_entity) - .updateOne(user.id, { - strategy_value: await strategy.hash(password as string), - }); - togglePw(false); - + if (await app.module.auth.changePassword(user.id, password)) { $log.success(`Updated user: ${c.cyan(user.email)}`); - } catch (e) { + } else { $log.error("Error updating user"); - $console.error(e); } } diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 98661c9..c506ae2 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -1,7 +1,7 @@ import { MediaAdapters } from "media/media-registry"; import { registries } from "modules/registries"; import { s, objectTransform } from "bknd/utils"; -import { $object, $record } from "modules/mcp"; +import { $object, $record, $schema } from "modules/mcp"; export const ADAPTERS = { ...MediaAdapters, @@ -39,7 +39,10 @@ export function buildMediaSchema() { }, { default: {} }, ), - adapter: s.anyOf(Object.values(adapterSchemaObject)).optional(), + adapter: $schema( + "config_media_adapter", + s.anyOf(Object.values(adapterSchemaObject)), + ).optional(), }, { default: {}, diff --git a/app/src/modules/mcp/$schema.ts b/app/src/modules/mcp/$schema.ts new file mode 100644 index 0000000..bc707ab --- /dev/null +++ b/app/src/modules/mcp/$schema.ts @@ -0,0 +1,72 @@ +import { Tool, getPath, s } from "bknd/utils"; +import { + McpSchemaHelper, + mcpSchemaSymbol, + type AppToolHandlerCtx, + type SchemaWithMcpOptions, +} from "./McpSchemaHelper"; + +export interface SchemaToolSchemaOptions extends s.ISchemaOptions, SchemaWithMcpOptions {} + +export const $schema = < + const S extends s.Schema, + const O extends SchemaToolSchemaOptions = SchemaToolSchemaOptions, +>( + name: string, + schema: S, + options?: O, +): S => { + const mcp = new McpSchemaHelper(schema, name, options || {}); + + const toolGet = (node: s.Node) => { + return new Tool( + [mcp.name, "get"].join("_"), + { + ...mcp.getToolOptions("get"), + inputSchema: s.strictObject({ + secrets: s + .boolean({ + default: false, + description: "Include secrets in the response config", + }) + .optional(), + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + const configs = ctx.context.app.toJSON(params.secrets); + const value = getPath(configs, node.instancePath); + + return ctx.json({ + secrets: params.secrets ?? false, + value: value ?? null, + }); + }, + ); + }; + + const toolUpdate = (node: s.Node) => { + return new Tool( + [mcp.name, "update"].join("_"), + { + ...mcp.getToolOptions("update"), + inputSchema: s.strictObject({ + full: s.boolean({ default: false }).optional(), + value: schema as any, + }), + }, + async (params, ctx: AppToolHandlerCtx) => { + return ctx.json(params); + }, + ); + }; + + const getTools = (node: s.Node) => { + const { tools = [] } = mcp.options; + return [toolGet(node), toolUpdate(node), ...tools]; + }; + + return Object.assign(schema, { + [mcpSchemaSymbol]: mcp, + getTools, + }); +}; diff --git a/app/src/modules/mcp/index.ts b/app/src/modules/mcp/index.ts index 4cead9a..9a19c09 100644 --- a/app/src/modules/mcp/index.ts +++ b/app/src/modules/mcp/index.ts @@ -1,3 +1,4 @@ export * from "./$object"; export * from "./$record"; +export * from "./$schema"; export * from "./McpSchemaHelper"; diff --git a/bun.lock b/bun.lock index 124121e..670c067 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.5.2-rc.1", + "jsonv-ts": "0.6.0", "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.5.2-rc.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-mGD7peeZcrr/GeeIWxYDZTSa2/LHSeP0cWIELR63WI9p+PWJRUuDOQ4pHcESbG/syyEBvuus4Nbljnlrxwi2bQ=="], + "jsonv-ts": ["jsonv-ts@0.6.0", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-U9u2Gtv5NyYwMvSHPcOwd9Qgg4cUFPepI5vTkwU7Ib01ipEqaRrM/AM+REEbYqiI6LX+7YF+HcMbgCIHkUMhSQ=="], "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=="],