auto generated tools docs, added stdio transport, added additional mcp config and permissions

This commit is contained in:
dswbx
2025-08-09 14:14:51 +02:00
parent 170ea2c45b
commit cb873381f1
25 changed files with 3770 additions and 87 deletions

View File

@@ -168,13 +168,12 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build({ fetch: options?.fetch });
const { guard, server } = this.modules.ctx();
const { guard } = this.modules.ctx();
// load system controller
guard.registerPermissions(Object.values(SystemPermissions));
const systemController = new SystemController(this);
systemController.registerMcp();
server.route("/api/system", systemController.getController());
systemController.register(this);
// emit built event
$console.log("App built");

View File

@@ -1,104 +1,85 @@
import type { CliCommand } from "cli/types";
import { makeAppFromEnv } from "../run";
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";
import { mcpSchemaSymbol } from "modules/mcp/McpSchemaHelper";
import { getVersion } from "cli/utils/sys";
import { getSystemMcp } from "modules/server/system-mcp";
import { $console } from "bknd/utils";
import { stdioTransport } from "jsonv-ts/mcp";
export const mcp: CliCommand = (program) =>
program
.command("mcp")
.description("mcp server")
.option("--verbose", "verbose output")
.description("mcp server stdio transport")
.option("--config <config>", "config file")
.option("--db-url <db>", "database url, can be any valid sqlite url")
.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("--verbose", "verbose output")
.option("--log-level <level>", "log level")
.option("--force", "force enable mcp")
.action(action);
async function action(options: {
verbose?: boolean;
config?: string;
dbUrl?: string;
port?: string;
path?: string;
token?: string;
logLevel?: string;
force?: boolean;
}) {
const verbose = !!options.verbose;
const __oldConsole = { ...console };
// disable console
if (!verbose) {
$console.disable();
Object.entries(console).forEach(([key]) => {
console[key] = () => null;
});
}
const app = await makeAppFromEnv({
config: options.config,
dbUrl: options.dbUrl,
server: "node",
});
const token = options.token || process.env.BEARER_TOKEN;
const middlewareServer = getMcpServer(app.server);
const appConfig = app.modules.configs();
const { version, ...appSchema } = app.getSchema();
const schema = s.strictObject(appSchema);
const nodes = [...schema.walk({ data: appConfig })].filter(
(n) => isObject(n.schema) && mcpSchemaSymbol in n.schema,
) as s.Node<McpSchema>[];
const tools = [
...middlewareServer.tools,
...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: await getVersion(),
},
{ app, ctx: () => app.modules.ctx() },
tools,
resources,
);
if (token) {
server.setAuthentication({
type: "bearer",
token,
});
if (!app.modules.get("server").config.mcp.enabled && !options.force) {
$console.enable();
Object.assign(console, __oldConsole);
console.error("MCP is not enabled in the config, use --force to enable it");
process.exit(1);
}
const hono = new Hono().use(
mcpMiddleware({
server,
sessionsEnabled: true,
debug: {
logLevel: options.logLevel as any,
explainEndpoint: true,
},
endpoint: {
path: String(options.path) as any,
},
}),
);
const token = options.token || process.env.BEARER_TOKEN;
const server = getSystemMcp(app);
serve({
fetch: hono.fetch,
port: Number(options.port) || 3000,
});
if (options.verbose) {
console.info(`Server is running on http://localhost:${options.port}${options.path}`);
if (verbose) {
console.info(
`⚙️ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`,
`\n⚙️ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`,
);
console.info(
`📚 Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`,
);
console.info("\nMCP server is running on STDIO transport");
}
if (options.logLevel) {
server.setLogLevel(options.logLevel as any);
}
const stdout = process.stdout;
const stdin = process.stdin;
const stderr = process.stderr;
{
using transport = stdioTransport(server, {
stdin,
stdout,
stderr,
raw: new Request("https://localhost", {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}),
});
}
}

View File

@@ -26,7 +26,7 @@ export async function getVersion(_path: string = "") {
return JSON.parse(pkg).version ?? "preview";
}
} catch (e) {
console.error("Failed to resolve version");
//console.error("Failed to resolve version");
}
return "unknown";

View File

@@ -76,6 +76,7 @@ declare global {
| {
level: TConsoleSeverity;
id?: string;
enabled?: boolean;
}
| undefined;
}
@@ -86,6 +87,7 @@ const defaultLevel = env("cli_log_level", "log") as TConsoleSeverity;
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
const config = (globalThis.__consoleConfig ??= {
level: defaultLevel,
enabled: true,
//id: crypto.randomUUID(), // for debugging
});
@@ -95,6 +97,14 @@ export const $console = new Proxy(config as any, {
switch (prop) {
case "original":
return console;
case "disable":
return () => {
config.enabled = false;
};
case "enable":
return () => {
config.enabled = true;
};
case "setLevel":
return (l: TConsoleSeverity) => {
config.level = l;
@@ -105,6 +115,10 @@ export const $console = new Proxy(config as any, {
};
}
if (!config.enabled) {
return () => null;
}
const current = keys.indexOf(config.level);
const requested = keys.indexOf(prop as string);
@@ -118,6 +132,8 @@ export const $console = new Proxy(config as any, {
} & {
setLevel: (l: TConsoleSeverity) => void;
resetLevel: () => void;
disable: () => void;
enable: () => void;
};
export function colorizeConsole(con: typeof console) {

View File

@@ -9,6 +9,7 @@ export {
type ConnQueryResults,
customIntrospector,
} from "./Connection";
export { DummyConnection } from "./DummyConnection";
// sqlite
export { SqliteConnection } from "./sqlite/SqliteConnection";

View File

@@ -130,6 +130,7 @@ export {
BaseIntrospector,
Connection,
customIntrospector,
DummyConnection,
type FieldSpec,
type IndexSpec,
type DbFunctions,

View File

@@ -116,12 +116,14 @@ export class ModuleHelper {
async throwUnlessGranted(
permission: Permission | string,
c: { context: ModuleBuildContextMcpContext; request: Request },
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
) {
invariant(c.context.app, "app is not available in mcp context");
invariant(c.request instanceof Request, "request is not available in mcp context");
invariant(c.raw instanceof Request, "request is not available in mcp context");
const user = await c.context.app.module.auth.authenticator.resolveAuthFromRequest(c.request);
const user = await c.context.app.module.auth.authenticator.resolveAuthFromRequest(
c.raw as Request,
);
if (!this.ctx.guard.granted(permission, user)) {
throw new Exception(

View File

@@ -6,6 +6,7 @@ import {
type McpSchema,
type SchemaWithMcpOptions,
} from "./McpSchemaHelper";
import type { Module } from "modules/Module";
export interface ObjectToolSchemaOptions extends s.IObjectOptions, SchemaWithMcpOptions {}
@@ -80,13 +81,36 @@ export class ObjectToolSchema<
...this.mcp.getToolOptions("update"),
inputSchema: s.strictObject({
full: s.boolean({ default: false }).optional(),
value: s
.strictObject(schema.properties as any)
.partial() as unknown as s.ObjectSchema<P, O>,
return_config: s
.boolean({
default: false,
description: "If the new configuration should be returned",
})
.optional(),
value: s.strictObject(schema.properties as {}).partial(),
}),
},
async (params, ctx: AppToolHandlerCtx) => {
return ctx.json(params);
const { full, value, return_config } = params;
const [module_name] = node.instancePath;
if (full) {
await ctx.context.app.mutateConfig(module_name as any).set(value);
} else {
await ctx.context.app.mutateConfig(module_name as any).patch("", value);
}
let config: any = undefined;
if (return_config) {
const configs = ctx.context.app.toJSON();
config = getPath(configs, node.instancePath);
}
return ctx.json({
success: true,
module: module_name,
config,
});
},
);
}

View File

@@ -7,3 +7,4 @@ export const configReadSecrets = new Permission("system.config.read.secrets");
export const configWrite = new Permission("system.config.write");
export const schemaRead = new Permission("system.schema.read");
export const build = new Permission("system.build");
export const mcp = new Permission("system.mcp");

View File

@@ -22,6 +22,9 @@ export const serverConfigSchema = $object(
}),
allow_credentials: s.boolean({ default: true }),
}),
mcp: s.strictObject({
enabled: s.boolean({ default: false }),
}),
},
{
description: "Server configuration",

View File

@@ -14,6 +14,7 @@ import {
InvalidSchemaError,
openAPISpecs,
mcpTool,
mcp as mcpMiddleware,
} from "bknd/utils";
import type { Context, Hono } from "hono";
import { Controller } from "modules/Controller";
@@ -27,6 +28,7 @@ import {
import * as SystemPermissions from "modules/permissions";
import { getVersion } from "core/env";
import type { Module } from "modules/Module";
import { getSystemMcp } from "./system-mcp";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
@@ -52,6 +54,32 @@ export class SystemController extends Controller {
return this.app.modules.ctx();
}
register(app: App) {
app.server.route("/api/system", this.getController());
if (!this.app.modules.get("server").config.mcp.enabled) {
return;
}
this.registerMcp();
const mcpServer = getSystemMcp(app);
app.server.use(
mcpMiddleware({
server: mcpServer,
sessionsEnabled: true,
debug: {
logLevel: "debug",
explainEndpoint: true,
},
endpoint: {
path: "/mcp",
},
}),
);
}
private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares;
// don't add auth again, it's already added in getController

View File

@@ -0,0 +1,36 @@
import type { App } from "App";
import { mcpSchemaSymbol, type McpSchema } from "modules/mcp";
import { getMcpServer, isObject, s, McpServer } from "bknd/utils";
import { getVersion } from "core/env";
export function getSystemMcp(app: App) {
const middlewareServer = getMcpServer(app.server);
const appConfig = app.modules.configs();
const { version, ...appSchema } = app.getSchema();
const schema = s.strictObject(appSchema);
const nodes = [...schema.walk({ data: appConfig })].filter(
(n) => isObject(n.schema) && mcpSchemaSymbol in n.schema,
) as s.Node<McpSchema>[];
const tools = [
// tools from hono routes
...middlewareServer.tools,
// tools added from ctx
...app.modules.ctx().mcp.tools,
// tools from app schema
...nodes.flatMap((n) => n.schema.getTools(n)),
];
const resources = [...middlewareServer.resources, ...app.modules.ctx().mcp.resources];
return new McpServer(
{
name: "bknd",
version: getVersion(),
},
{ app, ctx: () => app.modules.ctx() },
tools,
resources,
);
}