added authentication, $schema, fixed media adapter mcp

This commit is contained in:
dswbx
2025-08-07 11:33:46 +02:00
parent 1b02feca93
commit 42db5f55c7
9 changed files with 247 additions and 34 deletions

View File

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

View File

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

View File

@@ -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>", "port to listen on", "3000")
.option("--path <path>", "path to listen on", "/mcp")
.option(
"--token <token>",
"token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable",
)
.option("--log-level <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<McpSchema>[];
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: {

View File

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

View File

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

View File

@@ -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<S>) => {
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<S>) => {
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<S>) => {
const { tools = [] } = mcp.options;
return [toolGet(node), toolUpdate(node), ...tools];
};
return Object.assign(schema, {
[mcpSchemaSymbol]: mcp,
getTools,
});
};

View File

@@ -1,3 +1,4 @@
export * from "./$object";
export * from "./$record";
export * from "./$schema";
export * from "./McpSchemaHelper";