mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
auto generated tools docs, added stdio transport, added additional mcp config and permissions
This commit is contained in:
35
app/internal/docs.build-assets.ts
Normal file
35
app/internal/docs.build-assets.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createApp } from "bknd/adapter/bun";
|
||||
|
||||
async function generate() {
|
||||
console.info("Generating MCP documentation...");
|
||||
const app = await createApp({
|
||||
initialConfig: {
|
||||
server: {
|
||||
mcp: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
enabled: true,
|
||||
},
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: {
|
||||
type: "local",
|
||||
config: {
|
||||
path: "./",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.build();
|
||||
|
||||
const res = await app.server.request("/mcp?explain=1");
|
||||
const { tools, resources } = await res.json();
|
||||
await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2));
|
||||
|
||||
console.info("MCP documentation generated.");
|
||||
}
|
||||
|
||||
void generate();
|
||||
@@ -43,7 +43,8 @@
|
||||
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
"test:e2e:report": "playwright show-report",
|
||||
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
||||
},
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
type ConnQueryResults,
|
||||
customIntrospector,
|
||||
} from "./Connection";
|
||||
export { DummyConnection } from "./DummyConnection";
|
||||
|
||||
// sqlite
|
||||
export { SqliteConnection } from "./sqlite/SqliteConnection";
|
||||
|
||||
@@ -130,6 +130,7 @@ export {
|
||||
BaseIntrospector,
|
||||
Connection,
|
||||
customIntrospector,
|
||||
DummyConnection,
|
||||
type FieldSpec,
|
||||
type IndexSpec,
|
||||
type DbFunctions,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
36
app/src/modules/server/system-mcp.ts
Normal file
36
app/src/modules/server/system-mcp.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -32,12 +32,8 @@
|
||||
"*": ["./src/*"],
|
||||
"bknd": ["./src/index.ts"],
|
||||
"bknd/utils": ["./src/core/utils/index.ts"],
|
||||
"bknd/core": ["./src/core/index.ts"],
|
||||
"bknd/adapter": ["./src/adapter/index.ts"],
|
||||
"bknd/client": ["./src/ui/client/index.ts"],
|
||||
"bknd/data": ["./src/data/index.ts"],
|
||||
"bknd/media": ["./src/media/index.ts"],
|
||||
"bknd/auth": ["./src/auth/index.ts"]
|
||||
"bknd/client": ["./src/ui/client/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user