Skip to content

Commit c4be596

Browse files
authored
Merge pull request #572 from TencentCloudBase/ai-fix/issue-571
fix: 🤖 attempt fix for issue #571
2 parents 464967d + fc2ee32 commit c4be596

2 files changed

Lines changed: 202 additions & 35 deletions

File tree

mcp/src/tools/functions.test.ts

Lines changed: 110 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
2+
import os from "os";
3+
import path from "path";
14
import { beforeEach, describe, expect, it, vi } from "vitest";
25
import {
36
buildFunctionOperationErrorMessage,
@@ -127,47 +130,127 @@ describe("functions tool helpers", () => {
127130
expect(() => resolveEventFunctionRuntime("Ruby3.2")).toThrow(/Python3.9/);
128131
});
129132

130-
it("guides HTTP functions through anonymous-access follow-up without auto-creating gateway access", async () => {
133+
it("rejects Event-style handler input for HTTP createFunction", async () => {
131134
const result = await tools.manageFunctions.handler({
132135
action: "createFunction",
133136
func: {
134137
name: "httpDemo",
135138
type: "HTTP",
136139
runtime: "Nodejs18.15",
140+
handler: "index.main",
137141
},
138142
functionRootPath: "/tmp/cloudfunctions",
139143
});
140144

141145
const payload = JSON.parse(result.content[0].text);
142146

143-
expect(mockCreateFunction).toHaveBeenCalledWith({
144-
func: expect.objectContaining({
145-
name: "httpDemo",
146-
type: "HTTP",
147-
installDependency: false,
148-
}),
147+
expect(payload.success).toBe(false);
148+
expect(payload.message).toContain("默认不要传 func.handler");
149+
expect(payload.message).toContain("scf_bootstrap");
150+
expect(mockCreateFunction).not.toHaveBeenCalled();
151+
});
152+
153+
it("fails fast when local HTTP function directory lacks scf_bootstrap", async () => {
154+
const functionRootPath = mkdtempSync(path.join(os.tmpdir(), "http-fn-"));
155+
const functionDir = path.join(functionRootPath, "httpDemo");
156+
mkdirSync(functionDir);
157+
writeFileSync(
158+
path.join(functionDir, "package.json"),
159+
JSON.stringify({ name: "httpDemo", version: "1.0.0" }),
160+
);
161+
162+
try {
163+
const result = await tools.manageFunctions.handler({
164+
action: "createFunction",
165+
func: {
166+
name: "httpDemo",
167+
type: "HTTP",
168+
runtime: "Nodejs18.15",
169+
},
170+
functionRootPath,
171+
});
172+
173+
const payload = JSON.parse(result.content[0].text);
174+
expect(payload.success).toBe(false);
175+
expect(payload.message).toContain("必须包含 scf_bootstrap");
176+
expect(mockCreateFunction).not.toHaveBeenCalled();
177+
} finally {
178+
rmSync(functionRootPath, { recursive: true, force: true });
179+
}
180+
});
181+
182+
it("allows Event functions with handler", async () => {
183+
const result = await tools.manageFunctions.handler({
184+
action: "createFunction",
185+
func: {
186+
name: "eventDemo",
187+
type: "Event",
188+
handler: "index.main",
189+
},
149190
functionRootPath: "/tmp/cloudfunctions",
150-
force: false,
151191
});
152-
expect(mockCreateAccess).not.toHaveBeenCalled();
153-
expect(payload.message).toContain("manageGateway(action=\"createAccess\")");
154-
expect(payload.message).toContain("匿名身份访问");
155-
expect(payload.message).toContain("EXCEED_AUTHORITY");
156-
expect(payload.nextActions).toEqual(
157-
expect.arrayContaining([
158-
expect.objectContaining({
159-
tool: "manageGateway",
160-
action: "createAccess",
161-
}),
162-
expect.objectContaining({
163-
tool: "queryPermissions",
164-
action: "getResourcePermission",
165-
}),
166-
expect.objectContaining({
167-
tool: "managePermissions",
168-
action: "updateResourcePermission",
192+
193+
const payload = JSON.parse(result.content[0].text);
194+
expect(payload.success).toBe(true);
195+
});
196+
197+
it("guides HTTP functions through runtime verification and anonymous-access follow-up without auto-creating gateway access", async () => {
198+
const functionRootPath = mkdtempSync(path.join(os.tmpdir(), "http-fn-"));
199+
const functionDir = path.join(functionRootPath, "httpDemo");
200+
mkdirSync(functionDir);
201+
writeFileSync(path.join(functionDir, "index.js"), "require('node:http').createServer(() => {}).listen(9000);\n");
202+
writeFileSync(path.join(functionDir, "scf_bootstrap"), "#!/bin/sh\nnode index.js\n");
203+
chmodSync(path.join(functionDir, "scf_bootstrap"), 0o755);
204+
205+
try {
206+
const result = await tools.manageFunctions.handler({
207+
action: "createFunction",
208+
func: {
209+
name: "httpDemo",
210+
type: "HTTP",
211+
runtime: "Nodejs18.15",
212+
},
213+
functionRootPath,
214+
});
215+
216+
const payload = JSON.parse(result.content[0].text);
217+
218+
expect(mockCreateFunction).toHaveBeenCalledWith({
219+
func: expect.objectContaining({
220+
name: "httpDemo",
221+
type: "HTTP",
222+
installDependency: false,
169223
}),
170-
]),
171-
);
224+
functionRootPath,
225+
force: false,
226+
});
227+
expect(mockCreateAccess).not.toHaveBeenCalled();
228+
expect(payload.message).toContain("queryFunctions(action=\"listFunctionLogs\"");
229+
expect(payload.message).toContain("FUNCTION_EXECUTE_FAIL");
230+
expect(payload.message).toContain("匿名身份访问");
231+
expect(payload.message).toContain("EXCEED_AUTHORITY");
232+
expect(payload.nextActions).toEqual(
233+
expect.arrayContaining([
234+
expect.objectContaining({
235+
tool: "queryFunctions",
236+
action: "listFunctionLogs",
237+
}),
238+
expect.objectContaining({
239+
tool: "manageGateway",
240+
action: "createAccess",
241+
}),
242+
expect.objectContaining({
243+
tool: "queryPermissions",
244+
action: "getResourcePermission",
245+
}),
246+
expect.objectContaining({
247+
tool: "managePermissions",
248+
action: "updateResourcePermission",
249+
}),
250+
]),
251+
);
252+
} finally {
253+
rmSync(functionRootPath, { recursive: true, force: true });
254+
}
172255
});
173256
});

mcp/src/tools/functions.ts

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { jsonContent } from "../utils/json-content.js";
1010
import { debug } from "../utils/logger.js";
1111

1212
import { IEnvVariable } from "@cloudbase/manager-node/types/function/types.js";
13-
import { existsSync } from "fs";
13+
import { existsSync, readFileSync, statSync } from "fs";
1414
import path from "path";
1515

1616
export const SUPPORTED_RUNTIMES = {
@@ -233,7 +233,12 @@ const TRIGGER_SCHEMA = z.object({
233233

234234
const CREATE_FUNCTION_SCHEMA = z.object({
235235
name: z.string().describe("函数名称"),
236-
type: z.enum(["Event", "HTTP"]).optional().describe("函数类型"),
236+
type: z
237+
.enum(["Event", "HTTP"])
238+
.optional()
239+
.describe(
240+
"函数类型。Event 函数使用 exports.main(event, context) 这类 handler 模式;HTTP 函数使用 scf_bootstrap 启动 Web 服务并监听 9000 端口,不要把两种模式混用。",
241+
),
237242
protocolType: z.enum(["HTTP", "WS"]).optional().describe("HTTP 云函数协议类型"),
238243
protocolParams: z
239244
.object({
@@ -267,7 +272,12 @@ const CREATE_FUNCTION_SCHEMA = z.object({
267272
` Go: ${RECOMMENDED_RUNTIMES.golang}`,
268273
),
269274
triggers: z.array(TRIGGER_SCHEMA).optional().describe("触发器配置数组"),
270-
handler: z.string().optional().describe("函数入口"),
275+
handler: z
276+
.string()
277+
.optional()
278+
.describe(
279+
"函数入口。Event 函数使用 file.export 格式(如 index.main),表示 index.js 文件导出的 main 方法;HTTP 函数默认不要传 handler,运行时由 scf_bootstrap 启动。把 HTTP Web 服务代码和 handler 混用,容易出现部署成功但运行时报 FUNCTION_EXECUTE_FAIL。",
280+
),
271281
ignore: z.union([z.string(), z.array(z.string())]).optional().describe("忽略文件"),
272282
isWaitInstall: z.boolean().optional().describe("是否等待依赖安装"),
273283
layers: z
@@ -327,6 +337,48 @@ function getExpectedFunctionPath(
327337
return path.join(path.normalize(functionRootPath), functionName);
328338
}
329339

340+
export function validateHttpFunctionCreateInput(
341+
functionName: string,
342+
functionRootPath: string | undefined,
343+
zipFile: string | undefined,
344+
handler: unknown,
345+
): void {
346+
if (typeof handler === "string" && handler.trim()) {
347+
throw new Error(
348+
`createFunction 创建 HTTP 函数时,默认不要传 func.handler。HTTP 函数由 scf_bootstrap 启动,而不是 Event 函数的 handler 入口。请删除 func.handler,并确保 cloudfunctions/${functionName}/scf_bootstrap 启动你的 HTTP 服务且监听 9000 端口。`,
349+
);
350+
}
351+
352+
if (!functionRootPath || zipFile) {
353+
return;
354+
}
355+
356+
const expectedFunctionPath = getExpectedFunctionPath(functionRootPath, functionName);
357+
if (!expectedFunctionPath) {
358+
return;
359+
}
360+
361+
const scfBootstrapPath = path.join(expectedFunctionPath, "scf_bootstrap");
362+
if (!existsSync(scfBootstrapPath)) {
363+
throw new Error(
364+
`createFunction 创建 HTTP 函数时,函数目录 ${expectedFunctionPath} 下必须包含 scf_bootstrap 启动脚本。HTTP 函数由 scf_bootstrap 启动并监听 9000 端口,不能只依赖 handler。`,
365+
);
366+
}
367+
368+
const bootstrapContent = readFileSync(scfBootstrapPath, "utf8");
369+
if (bootstrapContent.includes("\r")) {
370+
throw new Error(
371+
`createFunction 创建 HTTP 函数时,${scfBootstrapPath} 不能使用 CRLF 行尾。请改为 LF 后重试,否则部署成功后仍可能因为启动脚本格式不兼容而运行失败。`,
372+
);
373+
}
374+
375+
if ((statSync(scfBootstrapPath).mode & 0o111) === 0) {
376+
throw new Error(
377+
`createFunction 创建 HTTP 函数时,${scfBootstrapPath} 必须有可执行权限。请先执行 chmod +x scf_bootstrap 后重试。`,
378+
);
379+
}
380+
}
381+
330382
export function shouldInstallDependencyForFunction(
331383
functionType: string | undefined,
332384
hasPackageJson: boolean,
@@ -391,6 +443,18 @@ export function buildFunctionOperationErrorMessage(
391443
);
392444
}
393445

446+
if (/BootstrapFile|Entryfile|scf_bootstrap|exec format error/i.test(baseMessage)) {
447+
suggestions.push(
448+
"请确认 HTTP 函数目录下存在精确命名的 scf_bootstrap 启动脚本,并且文件使用 LF 行尾。",
449+
);
450+
suggestions.push(
451+
"请确认 scf_bootstrap 已执行 chmod +x,且它真正启动的是监听 9000 端口的 HTTP 服务。",
452+
);
453+
suggestions.push(
454+
"HTTP 函数默认不要再传 handler;运行时由 scf_bootstrap 启动,混入 Event handler 容易导致部署成功但运行失败。",
455+
);
456+
}
457+
394458
if (suggestions.length === 0) {
395459
suggestions.push("请检查函数名、目录结构和环境中的函数状态后重试。");
396460
}
@@ -896,6 +960,15 @@ export function registerFunctionTools(server: ExtendedMcpServer) {
896960
);
897961
}
898962

963+
if (functionType === "HTTP") {
964+
validateHttpFunctionCreateInput(
965+
functionName,
966+
processedRootPath,
967+
input.zipFile,
968+
func.handler,
969+
);
970+
}
971+
899972
const hasPackageJson =
900973
expectedFunctionPath !== undefined
901974
? existsSync(path.join(expectedFunctionPath, "package.json"))
@@ -942,6 +1015,12 @@ export function registerFunctionTools(server: ExtendedMcpServer) {
9421015
];
9431016

9441017
if (func.type === "HTTP") {
1018+
nextActions.push({
1019+
tool: "queryFunctions",
1020+
action: "listFunctionLogs",
1021+
reason:
1022+
"HTTP 函数创建成功只表示代码已上传;交付前请先检查启动日志,确认没有 FUNCTION_EXECUTE_FAIL、scf_bootstrap 或 Entryfile 相关错误",
1023+
});
9451024
nextActions.push({
9461025
tool: "manageGateway",
9471026
action: "createAccess",
@@ -969,7 +1048,7 @@ export function registerFunctionTools(server: ExtendedMcpServer) {
9691048

9701049
const message =
9711050
func.type === "HTTP"
972-
? `已创建 HTTP 函数 ${functionName}。如果后续需要通过 URL 访问,请显式调用 manageGateway(action="createAccess") 按实际路径和鉴权需求创建访问入口。评测或其他外部调用方可能会以匿名身份访问,而且失败后不一定会把 EXCEED_AUTHORITY 再反馈给 AI;交付前请主动确认访问路径和函数安全规则,若已出现 EXCEED_AUTHORITY,请先调用 queryPermissions(action="getResourcePermission", resourceType="function", resourceId="${functionName}") 查看当前规则,再按需要使用 managePermissions(action="updateResourcePermission") 调整权限。`
1051+
? `已创建 HTTP 函数 ${functionName}注意:创建成功只表示代码包已上传,不代表 HTTP 启动链路已经验证通过。HTTP 函数由 scf_bootstrap 启动,请在交付前至少调用 queryFunctions(action="listFunctionLogs", functionName="${functionName}") 或实际访问入口,确认没有 FUNCTION_EXECUTE_FAIL、scf_bootstrap 或 Entryfile 相关错误。如果后续需要通过 URL 访问,请显式调用 manageGateway(action="createAccess") 按实际路径和鉴权需求创建访问入口。评测或其他外部调用方可能会以匿名身份访问,而且失败后不一定会把 EXCEED_AUTHORITY 再反馈给 AI;交付前请主动确认访问路径和函数安全规则,若已出现 EXCEED_AUTHORITY,请先调用 queryPermissions(action="getResourcePermission", resourceType="function", resourceId="${functionName}") 查看当前规则,再按需要使用 managePermissions(action="updateResourcePermission") 调整权限。`
9731052
: `已创建函数 ${functionName}`;
9741053

9751054
return buildEnvelope(
@@ -1442,16 +1521,21 @@ export function registerFunctionTools(server: ExtendedMcpServer) {
14421521
func: CREATE_FUNCTION_SCHEMA.optional().describe("createFunction 操作的函数配置"),
14431522
functionRootPath: z.string().optional().describe(
14441523
"创建或更新函数代码时默认推荐的本地目录方式。函数根目录(父目录绝对路径)。" +
1445-
"本地应按 cloudfunctions/<functionName>/index.js 布局," +
1446-
"此参数传 cloudfunctions 目录的绝对路径(如 /abs/path/cloudfunctions),不要传到函数名子目录。" +
1447-
"SDK 会自动拼接函数名子目录,无需预先压缩 zip 或 base64 编码。",
1524+
"本地应按 cloudfunctions/<functionName>/index.js 布局," +
1525+
"此参数传 cloudfunctions 目录的绝对路径(如 /abs/path/cloudfunctions),不要传到函数名子目录。" +
1526+
"SDK 会自动拼接函数名子目录,无需预先压缩 zip 或 base64 编码。" +
1527+
"如果 createFunction 的 func.type=HTTP,则对应函数子目录还必须包含 scf_bootstrap,且启动脚本要使用 LF 行尾并具备可执行权限。",
14481528
),
1529+
14491530
force: z.boolean().optional().describe("createFunction 时是否覆盖"),
14501531
functionName: z.string().optional().describe("函数名称。大多数 action 使用该字段作为统一目标"),
14511532
zipFile: z.string().optional().describe(
14521533
"仅兼容特殊场景:预先准备好的代码包 base64 编码。普通 createFunction/updateFunctionCode 默认不要先压缩 zip,优先使用 functionRootPath。",
14531534
),
1454-
handler: z.string().optional().describe("函数入口"),
1535+
handler: z.string().optional().describe(
1536+
"函数入口。Event 函数使用 file.export 格式(如 index.main);" +
1537+
"HTTP 函数不需要 handler,由 scf_bootstrap 决定入口,请不要为 HTTP 函数设置此字段。",
1538+
),
14551539
timeout: z.number().optional().describe("配置更新时的超时时间"),
14561540
envVariables: z.record(z.string()).optional().describe("配置更新时要合并的环境变量"),
14571541
vpc: VPC_SCHEMA.optional().describe("配置更新时的 VPC 信息"),

0 commit comments

Comments
 (0)