mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
mcp: added auth tests, updated data tests
This commit is contained in:
@@ -1,32 +1,40 @@
|
||||
import { describe, beforeAll } from "bun:test";
|
||||
import { type App, createApp } from "core/test/utils";
|
||||
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
|
||||
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||
import { getSystemMcp } from "modules/mcp/system-mcp";
|
||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
/**
|
||||
* - [ ] auth_me
|
||||
* - [ ] auth_strategies
|
||||
* - [ ] auth_user_create
|
||||
* - [ ] auth_user_token
|
||||
* - [ ] auth_user_password_change
|
||||
* - [ ] auth_user_password_test
|
||||
* - [ ] config_auth_update
|
||||
* - [ ] config_auth_strategies_get
|
||||
* - [ ] config_auth_strategies_add
|
||||
* - [ ] config_auth_strategies_update
|
||||
* - [ ] config_auth_strategies_remove
|
||||
* - [ ] config_auth_roles_get
|
||||
* - [ ] config_auth_roles_add
|
||||
* - [ ] config_auth_roles_update
|
||||
* - [ ] config_auth_roles_remove
|
||||
* - [x] auth_me
|
||||
* - [x] auth_strategies
|
||||
* - [x] auth_user_create
|
||||
* - [x] auth_user_token
|
||||
* - [x] auth_user_password_change
|
||||
* - [x] auth_user_password_test
|
||||
* - [x] config_auth_get
|
||||
* - [x] config_auth_update
|
||||
* - [x] config_auth_strategies_get
|
||||
* - [x] config_auth_strategies_add
|
||||
* - [x] config_auth_strategies_update
|
||||
* - [x] config_auth_strategies_remove
|
||||
* - [x] config_auth_roles_get
|
||||
* - [x] config_auth_roles_add
|
||||
* - [x] config_auth_roles_update
|
||||
* - [x] config_auth_roles_remove
|
||||
*/
|
||||
describe("mcp auth", async () => {
|
||||
let app: App;
|
||||
let server: ReturnType<typeof getSystemMcp>;
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
app = createApp({
|
||||
initialConfig: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
},
|
||||
},
|
||||
server: {
|
||||
mcp: {
|
||||
@@ -37,5 +45,182 @@ describe("mcp auth", async () => {
|
||||
});
|
||||
await app.build();
|
||||
server = getSystemMcp(app);
|
||||
server.setLogLevel("error");
|
||||
server.onNotification((message) => {
|
||||
console.dir(message, { depth: null });
|
||||
});
|
||||
});
|
||||
|
||||
const tool = createMcpToolCaller();
|
||||
|
||||
test("auth_*", async () => {
|
||||
const me = await tool(server, "auth_me", {});
|
||||
expect(me.user).toBeNull();
|
||||
|
||||
// strategies
|
||||
const strategies = await tool(server, "auth_strategies", {});
|
||||
expect(Object.keys(strategies.strategies).length).toEqual(1);
|
||||
expect(strategies.strategies.password.enabled).toBe(true);
|
||||
|
||||
// create user
|
||||
const user = await tool(
|
||||
server,
|
||||
"auth_user_create",
|
||||
{
|
||||
email: "test@test.com",
|
||||
password: "12345678",
|
||||
},
|
||||
new Headers(),
|
||||
);
|
||||
expect(user.email).toBe("test@test.com");
|
||||
|
||||
// create token
|
||||
const token = await tool(
|
||||
server,
|
||||
"auth_user_token",
|
||||
{
|
||||
email: "test@test.com",
|
||||
},
|
||||
new Headers(),
|
||||
);
|
||||
expect(token.token).toBeDefined();
|
||||
expect(token.user.email).toBe("test@test.com");
|
||||
|
||||
// me
|
||||
const me2 = await tool(
|
||||
server,
|
||||
"auth_me",
|
||||
{},
|
||||
new Request("http://localhost", {
|
||||
headers: new Headers({
|
||||
Authorization: `Bearer ${token.token}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(me2.user.email).toBe("test@test.com");
|
||||
|
||||
// change password
|
||||
const changePassword = await tool(
|
||||
server,
|
||||
"auth_user_password_change",
|
||||
{
|
||||
email: "test@test.com",
|
||||
password: "87654321",
|
||||
},
|
||||
new Headers(),
|
||||
);
|
||||
expect(changePassword.changed).toBe(true);
|
||||
|
||||
// test password
|
||||
const testPassword = await tool(
|
||||
server,
|
||||
"auth_user_password_test",
|
||||
{
|
||||
email: "test@test.com",
|
||||
password: "87654321",
|
||||
},
|
||||
new Headers(),
|
||||
);
|
||||
expect(testPassword.valid).toBe(true);
|
||||
});
|
||||
|
||||
test("config_auth_{get,update}", async () => {
|
||||
expect(await tool(server, "config_auth_get", {})).toEqual({
|
||||
path: "",
|
||||
secrets: false,
|
||||
partial: false,
|
||||
value: app.toJSON().auth,
|
||||
});
|
||||
|
||||
// update
|
||||
await tool(server, "config_auth_update", {
|
||||
value: {
|
||||
allow_register: false,
|
||||
},
|
||||
});
|
||||
expect(app.toJSON().auth.allow_register).toBe(false);
|
||||
});
|
||||
|
||||
test("config_auth_strategies_{get,add,update,remove}", async () => {
|
||||
const strategies = await tool(server, "config_auth_strategies_get", {
|
||||
key: "password",
|
||||
});
|
||||
expect(strategies).toEqual({
|
||||
secrets: false,
|
||||
module: "auth",
|
||||
key: "password",
|
||||
value: {
|
||||
enabled: true,
|
||||
type: "password",
|
||||
},
|
||||
});
|
||||
|
||||
// add google oauth
|
||||
const addGoogleOauth = await tool(server, "config_auth_strategies_add", {
|
||||
key: "google",
|
||||
value: {
|
||||
type: "oauth",
|
||||
enabled: true,
|
||||
config: {
|
||||
name: "google",
|
||||
type: "oidc",
|
||||
client: {
|
||||
client_id: "client_id",
|
||||
client_secret: "client_secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
return_config: true,
|
||||
});
|
||||
expect(addGoogleOauth.config.google.enabled).toBe(true);
|
||||
expect(app.toJSON().auth.strategies.google?.enabled).toBe(true);
|
||||
|
||||
// update (disable) google oauth
|
||||
await tool(server, "config_auth_strategies_update", {
|
||||
key: "google",
|
||||
value: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
expect(app.toJSON().auth.strategies.google?.enabled).toBe(false);
|
||||
|
||||
// remove google oauth
|
||||
await tool(server, "config_auth_strategies_remove", {
|
||||
key: "google",
|
||||
});
|
||||
expect(app.toJSON().auth.strategies.google).toBeUndefined();
|
||||
});
|
||||
|
||||
test("config_auth_roles_{get,add,update,remove}", async () => {
|
||||
// add role
|
||||
const addGuestRole = await tool(server, "config_auth_roles_add", {
|
||||
key: "guest",
|
||||
value: {
|
||||
permissions: ["read", "write"],
|
||||
},
|
||||
return_config: true,
|
||||
});
|
||||
expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]);
|
||||
|
||||
// update role
|
||||
await tool(server, "config_auth_roles_update", {
|
||||
key: "guest",
|
||||
value: {
|
||||
permissions: ["read"],
|
||||
},
|
||||
});
|
||||
expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]);
|
||||
|
||||
// get role
|
||||
const getGuestRole = await tool(server, "config_auth_roles_get", {
|
||||
key: "guest",
|
||||
});
|
||||
expect(getGuestRole.value.permissions).toEqual(["read"]);
|
||||
|
||||
// remove role
|
||||
await tool(server, "config_auth_roles_remove", {
|
||||
key: "guest",
|
||||
});
|
||||
expect(app.toJSON().auth.roles?.guest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("mcp data", async () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.module).toBe("data");
|
||||
expect(result.config.entities.test.type).toEqual("regular");
|
||||
expect(result.config.test?.type).toEqual("regular");
|
||||
|
||||
const entities = Object.keys(app.toJSON().data.entities ?? {});
|
||||
expect(entities).toContain("test");
|
||||
@@ -94,7 +94,7 @@ describe("mcp data", async () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.module).toBe("data");
|
||||
expect(result.config.entities.test.config?.name).toEqual("Test");
|
||||
expect(result.config.test.config?.name).toEqual("Test");
|
||||
expect(app.toJSON().data.entities?.test?.config?.name).toEqual("Test");
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"hono": "4.8.3",
|
||||
"json-schema-library": "10.0.0-rc7",
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
"jsonv-ts": "^0.7.4",
|
||||
"jsonv-ts": "^0.7.5",
|
||||
"kysely": "0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
|
||||
@@ -72,6 +72,7 @@ export const authConfigSchema = $object(
|
||||
},
|
||||
s.strictObject({
|
||||
type: s.string(),
|
||||
enabled: s.boolean({ default: true }).optional(),
|
||||
config: s.object({}),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -385,7 +385,11 @@ export class Authenticator<
|
||||
headers = c.headers;
|
||||
} else {
|
||||
is_context = true;
|
||||
headers = c.req.raw.headers;
|
||||
try {
|
||||
headers = c.req.raw.headers;
|
||||
} catch (e) {
|
||||
throw new Exception("Request/Headers/Context is required to resolve auth", 400);
|
||||
}
|
||||
}
|
||||
|
||||
let token: string | undefined;
|
||||
|
||||
@@ -13,15 +13,18 @@ export function createApp({ connection, ...config }: CreateAppConfig = {}) {
|
||||
}
|
||||
|
||||
export function createMcpToolCaller() {
|
||||
return async (server: ReturnType<typeof getSystemMcp>, name: string, args: any) => {
|
||||
const res = await server.handle({
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name,
|
||||
arguments: args,
|
||||
return async (server: ReturnType<typeof getSystemMcp>, name: string, args: any, raw?: any) => {
|
||||
const res = await server.handle(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name,
|
||||
arguments: args,
|
||||
},
|
||||
},
|
||||
});
|
||||
raw,
|
||||
);
|
||||
|
||||
if ((res.result as any)?.isError) {
|
||||
console.dir(res.result, { depth: null });
|
||||
|
||||
@@ -80,9 +80,19 @@ export const dataConfigSchema = $object("config_data", {
|
||||
basepath: s.string({ default: "/api/data" }).optional(),
|
||||
default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(),
|
||||
entities: $record("config_data_entities", entitiesSchema, { default: {} }).optional(),
|
||||
relations: $record("config_data_relations", s.anyOf(relationsSchema), {
|
||||
default: {},
|
||||
}).optional(),
|
||||
relations: $record(
|
||||
"config_data_relations",
|
||||
s.anyOf(relationsSchema),
|
||||
{
|
||||
default: {},
|
||||
},
|
||||
s.strictObject({
|
||||
type: s.string({ enum: Object.keys(RelationClassMap) }),
|
||||
source: s.string(),
|
||||
target: s.string(),
|
||||
config: s.object({}).optional(),
|
||||
}),
|
||||
).optional(),
|
||||
indices: $record("config_data_indices", indicesSchema, {
|
||||
default: {},
|
||||
mcp: { update: false },
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ModuleBuildContext, ModuleBuildContextMcpContext } from "./Module"
|
||||
import type { EntityRelation } from "data/relations";
|
||||
import type { Permission } from "core/security/Permission";
|
||||
import { Exception } from "core/errors";
|
||||
import { invariant } from "bknd/utils";
|
||||
import { invariant, isPlainObject } from "bknd/utils";
|
||||
|
||||
export class ModuleHelper {
|
||||
constructor(protected ctx: Omit<ModuleBuildContext, "helper">) {}
|
||||
@@ -119,12 +119,14 @@ export class ModuleHelper {
|
||||
c: { context: ModuleBuildContextMcpContext; raw?: unknown },
|
||||
) {
|
||||
invariant(c.context.app, "app is not available in mcp context");
|
||||
invariant(c.raw instanceof Request, "request is not available in mcp context");
|
||||
|
||||
const auth = c.context.app.module.auth;
|
||||
if (!auth.enabled) return;
|
||||
|
||||
const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as Request);
|
||||
if (c.raw === undefined || c.raw === null) {
|
||||
throw new Exception("Request/Headers/Context is not available in mcp context", 400);
|
||||
}
|
||||
|
||||
const user = await auth.authenticator?.resolveAuthFromRequest(c.raw as any);
|
||||
|
||||
if (!this.ctx.guard.granted(permission, user)) {
|
||||
throw new Exception(
|
||||
|
||||
@@ -42,7 +42,7 @@ export class RecordToolSchema<
|
||||
}
|
||||
|
||||
private getNewSchema(fallback: s.Schema = this.additionalProperties) {
|
||||
return this[opts].new_schema ?? fallback;
|
||||
return this[opts].new_schema ?? this.additionalProperties ?? fallback;
|
||||
}
|
||||
|
||||
private toolGet(node: s.Node<RecordToolSchema<AP, O>>) {
|
||||
@@ -122,7 +122,7 @@ export class RecordToolSchema<
|
||||
}),
|
||||
},
|
||||
async (params, ctx: AppToolHandlerCtx) => {
|
||||
const configs = ctx.context.app.toJSON();
|
||||
const configs = ctx.context.app.toJSON(true);
|
||||
const config = getPath(configs, node.instancePath);
|
||||
const [module_name, ...rest] = node.instancePath;
|
||||
|
||||
@@ -134,12 +134,16 @@ export class RecordToolSchema<
|
||||
.mutateConfig(module_name as any)
|
||||
.patch([...rest, params.key], params.value);
|
||||
|
||||
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
module: module_name,
|
||||
config: params.return_config
|
||||
? ctx.context.app.module[module_name as any].config
|
||||
: undefined,
|
||||
action: {
|
||||
type: "add",
|
||||
key: params.key,
|
||||
},
|
||||
config: params.return_config ? newConfig : undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -154,7 +158,7 @@ export class RecordToolSchema<
|
||||
key: s.string({
|
||||
description: "key to update",
|
||||
}),
|
||||
value: this.getNewSchema(s.object({})),
|
||||
value: this.mcp.getCleanSchema(this.getNewSchema(s.object({}))),
|
||||
return_config: s
|
||||
.boolean({
|
||||
default: false,
|
||||
@@ -164,7 +168,7 @@ export class RecordToolSchema<
|
||||
}),
|
||||
},
|
||||
async (params, ctx: AppToolHandlerCtx) => {
|
||||
const configs = ctx.context.app.toJSON(params.secrets);
|
||||
const configs = ctx.context.app.toJSON(true);
|
||||
const config = getPath(configs, node.instancePath);
|
||||
const [module_name, ...rest] = node.instancePath;
|
||||
|
||||
@@ -176,12 +180,16 @@ export class RecordToolSchema<
|
||||
.mutateConfig(module_name as any)
|
||||
.patch([...rest, params.key], params.value);
|
||||
|
||||
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
module: module_name,
|
||||
config: params.return_config
|
||||
? ctx.context.app.module[module_name as any].config
|
||||
: undefined,
|
||||
action: {
|
||||
type: "update",
|
||||
key: params.key,
|
||||
},
|
||||
config: params.return_config ? newConfig : undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -205,7 +213,7 @@ export class RecordToolSchema<
|
||||
}),
|
||||
},
|
||||
async (params, ctx: AppToolHandlerCtx) => {
|
||||
const configs = ctx.context.app.toJSON();
|
||||
const configs = ctx.context.app.toJSON(true);
|
||||
const config = getPath(configs, node.instancePath);
|
||||
const [module_name, ...rest] = node.instancePath;
|
||||
|
||||
@@ -217,12 +225,16 @@ export class RecordToolSchema<
|
||||
.mutateConfig(module_name as any)
|
||||
.remove([...rest, params.key].join("."));
|
||||
|
||||
const newConfig = getPath(ctx.context.app.toJSON(), node.instancePath);
|
||||
|
||||
return ctx.json({
|
||||
success: true,
|
||||
module: module_name,
|
||||
config: params.return_config
|
||||
? ctx.context.app.module[module_name as any].config
|
||||
: undefined,
|
||||
action: {
|
||||
type: "remove",
|
||||
key: params.key,
|
||||
},
|
||||
config: params.return_config ? newConfig : undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -43,16 +43,18 @@ export class McpSchemaHelper<AdditionalOptions = {}> {
|
||||
public name: string,
|
||||
public options: McpToolOptions & AdditionalOptions,
|
||||
) {
|
||||
this.cleanSchema = this.getCleanSchema();
|
||||
this.cleanSchema = this.getCleanSchema(this.schema as s.ObjectSchema);
|
||||
}
|
||||
|
||||
private getCleanSchema() {
|
||||
getCleanSchema(schema: s.ObjectSchema) {
|
||||
if (schema.type !== "object") return schema;
|
||||
|
||||
const props = excludePropertyTypes(
|
||||
this.schema as any,
|
||||
schema as any,
|
||||
(i) => isPlainObject(i) && mcpSchemaSymbol in i,
|
||||
);
|
||||
const schema = s.strictObject(props);
|
||||
return rescursiveClean(schema, {
|
||||
const _schema = s.strictObject(props);
|
||||
return rescursiveClean(_schema, {
|
||||
removeRequired: true,
|
||||
removeDefault: false,
|
||||
}) as s.ObjectSchema<any, any>;
|
||||
|
||||
4
bun.lock
4
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.7.4",
|
||||
"jsonv-ts": "^0.7.5",
|
||||
"kysely": "0.27.6",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
@@ -2516,7 +2516,7 @@
|
||||
|
||||
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
||||
|
||||
"jsonv-ts": ["jsonv-ts@0.7.4", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-SDx7Nt1kku6mAefrMffIdA9INqJnRLDJVooQOlstDmn0SvmTEHNAPifB+S14RR3f+Lep1T+WUeUdrHADrZsnYA=="],
|
||||
"jsonv-ts": ["jsonv-ts@0.7.5", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-/FXLINo/mbMLVFD4zjNRFfWe5D9oBsc2H9Fy/KLgmdGdhgUo9T/xbVteGWBVQSPg+P2hPdbVgaKFWgvDPk4qVw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
1337
docs/mcp.json
1337
docs/mcp.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user