diff --git a/mcp/src/server.test.ts b/mcp/src/server.test.ts index e399d7ef..9dea5c6b 100644 --- a/mcp/src/server.test.ts +++ b/mcp/src/server.test.ts @@ -67,7 +67,48 @@ vi.mock("./utils/cloud-mode.js", () => ({ isCloudMode: vi.fn(() => false), })); vi.mock("./utils/tencent-cloud.js", () => ({ isInternationalRegion: vi.fn(() => false) })); -vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ SetLevelRequestSchema: {} })); +vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ + ListToolsRequestSchema: { shape: { method: { value: "tools/list" } } }, + SetLevelRequestSchema: { shape: { method: { value: "logging/setLevel" } } }, +})); + +describe("server tool schema overrides", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("should require username and password in listTools schema when action=createUser", async () => { + const { applyManagePermissionsCreateUserRequirements } = await import("./server.js"); + + const result = applyManagePermissionsCreateUserRequirements({ + type: "object", + properties: { + action: { type: "string" }, + username: { type: "string" }, + password: { type: "string" }, + }, + required: ["action"], + }); + + expect(result).toMatchObject({ + required: ["action"], + allOf: [ + { + if: { + properties: { + action: { const: "createUser" }, + }, + required: ["action"], + }, + then: { + required: ["username", "password"], + }, + }, + ], + }); + }); +}); describe("server plugin registration", () => { beforeEach(() => { diff --git a/mcp/src/server.ts b/mcp/src/server.ts index 14a5063f..35d8821a 100644 --- a/mcp/src/server.ts +++ b/mcp/src/server.ts @@ -1,4 +1,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; import { registerDatabaseTools } from "./tools/databaseNoSQL.js"; import { registerSQLDatabaseTools } from "./tools/databaseSQL.js"; import { registerDownloadTools } from "./tools/download.js"; @@ -9,7 +11,7 @@ import { registerRagTools } from "./tools/rag.js"; import { registerSetupTools } from "./tools/setup.js"; import { registerStorageTools } from "./tools/storage.js"; // import { registerMiniprogramTools } from "./tools/miniprogram.js"; -import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { ListToolsRequestSchema, SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { registerCapiTools } from "./tools/capi.js"; import { registerCloudRunTools } from "./tools/cloudrun.js"; import { registerDataModelTools } from "./tools/dataModel.js"; @@ -34,6 +36,84 @@ interface PluginDefinition { register: (server: ExtendedMcpServer) => void | Promise; } +type JsonSchemaObject = { + type?: string; + properties?: Record; + required?: string[]; + allOf?: unknown[]; + [key: string]: unknown; +}; + +const EMPTY_OBJECT_JSON_SCHEMA: JsonSchemaObject = { + type: "object", + properties: {}, +}; + +export function applyManagePermissionsCreateUserRequirements(inputSchema: JsonSchemaObject): JsonSchemaObject { + if ( + inputSchema?.type !== "object" || + !inputSchema.properties?.action || + !inputSchema.properties?.username || + !inputSchema.properties?.password + ) { + return inputSchema; + } + + const existingAllOf = Array.isArray(inputSchema.allOf) ? inputSchema.allOf : []; + + return { + ...inputSchema, + allOf: [ + ...existingAllOf, + { + if: { + properties: { + action: { const: "createUser" }, + }, + required: ["action"], + }, + then: { + required: ["username", "password"], + }, + }, + ], + }; +} + +function overrideListToolsHandler(server: ExtendedMcpServer) { + const registeredTools = (server as unknown as { _registeredTools: Record }) + ._registeredTools; + + server.server.setRequestHandler(ListToolsRequestSchema, () => ({ + tools: Object.entries(registeredTools) + .filter(([, tool]: [string, RegisteredTool]) => tool.enabled) + .map(([name, tool]: [string, RegisteredTool]) => { + const inputSchema = tool.inputSchema + ? (zodToJsonSchema(tool.inputSchema, { strictUnions: true }) as JsonSchemaObject) + : EMPTY_OBJECT_JSON_SCHEMA; + + const toolDefinition: Record = { + name, + title: tool.title, + description: tool.description, + inputSchema: + name === "managePermissions" + ? applyManagePermissionsCreateUserRequirements(inputSchema) + : inputSchema, + annotations: tool.annotations, + }; + + if (tool.outputSchema) { + toolDefinition.outputSchema = zodToJsonSchema(tool.outputSchema, { + strictUnions: true, + }); + } + + return toolDefinition; + }), + })); +} + // 默认插件列表 const DEFAULT_PLUGINS = [ "env", @@ -284,6 +364,8 @@ export async function createCloudBaseMcpServer(options?: { } } + overrideListToolsHandler(server); + return server; } diff --git a/mcp/src/tools/permissions.test.ts b/mcp/src/tools/permissions.test.ts index 6b34fdc4..a43a4d0c 100644 --- a/mcp/src/tools/permissions.test.ts +++ b/mcp/src/tools/permissions.test.ts @@ -324,18 +324,14 @@ describe("permission tools", () => { }); const payload = JSON.parse(result.content[0].text); - expect(mockCreateUser).toHaveBeenCalledWith({ - name: "bob", - password: "secret123", - userStatus: undefined, - description: undefined, - type: undefined, - nickName: undefined, - phone: undefined, - email: undefined, - avatarUrl: undefined, - uid: undefined, - }); + expect(mockCreateUser).toHaveBeenCalledWith( + expect.objectContaining({ + name: "bob", + password: "secret123", + userStatus: undefined, + description: undefined, + }), + ); expect(payload).toMatchObject({ success: true, data: { diff --git a/mcp/src/tools/permissions.ts b/mcp/src/tools/permissions.ts index 350bc3f3..53700e00 100644 --- a/mcp/src/tools/permissions.ts +++ b/mcp/src/tools/permissions.ts @@ -30,6 +30,17 @@ type QueryPermissionAction = (typeof QUERY_PERMISSION_ACTIONS)[number]; type ManagePermissionAction = (typeof MANAGE_PERMISSION_ACTIONS)[number]; type LegacyResourceType = "noSqlDatabase" | "sqlDatabase" | "function" | "storage"; +const SECURITY_RULE_DESCRIPTION = + "资源类型特定的规则内容,详细语义依赖 `resourceType`。当 `resourceType=\"noSqlDatabase\"` 且 `permission=\"CUSTOM\"` 时,应传文档数据库安全规则 JSON(文档型数据库规则:`https://docs.cloudbase.net/database/security-rules`);键通常为 `read` / `create` / `update` / `delete`,值为表达式。" + + "重要:`create` 规则验证写入数据,此时文档尚不存在,不能使用 `doc.*`;`read` / `update` / `delete` 规则可使用 `doc.*` 引用已有文档字段。" + + "不要把 `doc._openid`、`auth.openid`、查询条件子集校验或 `create` / `update` / `delete` 模板误用于 `function`、`storage` 或 `sqlDatabase`。" + + '如需配置 `function` 或 `storage`,请改查官方安全规则文档:云函数 `https://docs.cloudbase.net/cloud-function/security-rules`,云存储 `https://docs.cloudbase.net/storage/security-rules`。示例:{"read":"auth.uid != null","create":"auth.uid != null && auth.loginType != "ANONYMOUS"","update":"auth.uid != null && doc._openid == auth.openid","delete":"auth.uid != null && doc._openid == auth.openid"}'; + +const USERNAME_DESCRIPTION = + '用户名。**`action="createUser"` 时必填**,需与 `password` 一起提供。'; +const PASSWORD_DESCRIPTION = + '密码。**`action="createUser"` 时必填**,缺失会导致创建用户失败;如果用户未指定密码,请先和用户确认或生成一个安全的随机密码再调用工具,不要遗漏该参数。`action="updateUser"` 时可选,仅在需要重置密码时提供。'; + type ToolEnvelope = { success: boolean; data: Record; @@ -563,7 +574,7 @@ export function registerPermissionTools(server: ExtendedMcpServer) { { title: "管理权限与用户配置", description: - "权限域统一写入口。支持修改资源权限、角色管理、成员与策略增删、应用用户 CRUD。`createUser` / `updateUser` 是环境侧应用用户管理能力,适合测试账号、管理员或预置用户,不应替代浏览器里的 Web SDK 注册表单;前端用户名密码注册应使用 `auth.signUp({ username, password })`,登录应使用 `auth.signInWithPassword({ username, password })`。注意:`securityRule` 的详细语义取决于 `resourceType`;`doc._openid`、`auth.openid`、查询条件子集校验,以及 `create` / `update` / `delete` JSON 模板仅适用于 `resourceType=\"noSqlDatabase\"` 的文档数据库安全规则。配置 `function` 或 `storage` 时,请参考各自官方安全规则文档,而不是复用 NoSQL 模板。", + "权限域统一写入口。支持修改资源权限、角色管理、成员与策略增删、应用用户 CRUD。`createUser` / `updateUser` 是环境侧应用用户管理能力,适合测试账号、管理员或预置用户,不应替代浏览器里的 Web SDK 注册表单;前端用户名密码注册应使用 `auth.signUp({ username, password })`,登录应使用 `auth.signInWithPassword({ username, password })`。**必填参数提示:`action=\"createUser\"` 时必须同时提供 `username` 和 `password` 两个参数;如果用户没有显式给出密码,请在调用工具之前先与用户确认密码或自行生成一个安全的随机密码,不要省略 `password` 字段。** 注意:`securityRule` 的详细语义取决于 `resourceType`;`doc._openid`、`auth.openid`、查询条件子集校验,以及 `create` / `update` / `delete` JSON 模板仅适用于 `resourceType=\"noSqlDatabase\"` 的文档数据库安全规则。配置 `function` 或 `storage` 时,请参考各自官方安全规则文档,而不是复用 NoSQL 模板。", inputSchema: { action: z.enum(MANAGE_PERMISSION_ACTIONS), resourceType: z @@ -574,15 +585,7 @@ export function registerPermissionTools(server: ExtendedMcpServer) { permission: z .enum(["READONLY", "PRIVATE", "ADMINWRITE", "ADMINONLY", "CUSTOM"]) .optional(), - securityRule: z - .string() - .optional() - .describe( - "资源类型特定的规则内容,详细语义依赖 `resourceType`。当 `resourceType=\"noSqlDatabase\"` 且 `permission=\"CUSTOM\"` 时,应传文档数据库安全规则 JSON(文档型数据库规则:`https://docs.cloudbase.net/database/security-rules`);键通常为 `read` / `create` / `update` / `delete`,值为表达式。" + - "重要:`create` 规则验证写入数据,此时文档尚不存在,不能使用 `doc.*`;`read` / `update` / `delete` 规则可使用 `doc.*` 引用已有文档字段。" + - "不要把 `doc._openid`、`auth.openid`、查询条件子集校验或 `create` / `update` / `delete` 模板误用于 `function`、`storage` 或 `sqlDatabase`。" + - '如需配置 `function` 或 `storage`,请改查官方安全规则文档:云函数 `https://docs.cloudbase.net/cloud-function/security-rules`,云存储 `https://docs.cloudbase.net/storage/security-rules`。示例:{"read":"auth.uid != null","create":"auth.uid != null && auth.loginType != "ANONYMOUS"","update":"auth.uid != null && doc._openid == auth.openid","delete":"auth.uid != null && doc._openid == auth.openid"}', - ), + securityRule: z.string().optional().describe(SECURITY_RULE_DESCRIPTION), roleId: z.string().optional(), roleIds: z.array(z.string()).optional(), roleName: z.string().optional(), @@ -592,8 +595,8 @@ export function registerPermissionTools(server: ExtendedMcpServer) { policies: z.array(z.record(z.any())).optional(), uid: z.string().optional(), uids: z.array(z.string()).optional(), - username: z.string().optional(), - password: z.string().optional(), + username: z.string().optional().describe(USERNAME_DESCRIPTION), + password: z.string().optional().describe(PASSWORD_DESCRIPTION), userStatus: z.enum(["ACTIVE", "BLOCKED"]).optional(), }, annotations: {