Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
335 changes: 272 additions & 63 deletions mcp/package-lock.json

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
"@cloudbase/manager-node": "5.6.6",
"@cloudbase/mcp": "1.0.0-beta.25",
"@cloudbase/toolbox": "^0.8.1",
"@modelcontextprotocol/sdk": "1.13.1",
"@modelcontextprotocol/sdk": "1.30.0",
"@types/adm-zip": "^0.5.7",
"adm-zip": "0.6.0",
"cors": "^2.8.5",
Expand All @@ -83,11 +83,10 @@
"winston": "^3.15.0",
"winston-daily-rotate-file": "^5.0.0",
"ws": "8.21.0",
"zod": "3.24.3"
"zod": "3.25.76"
},
"devDependencies": {
"@microsoft/api-extractor": "^7.57.6",
"@modelcontextprotocol/sdk": "^1.13.3",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-inject": "^5.0.5",
"@rollup/plugin-json": "^6.1.0",
Expand Down
77 changes: 77 additions & 0 deletions mcp/src/annotations-category.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { ToolAnnotations as SdkToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
import {
applyCategoryAnnotationMeta,
type ToolAnnotations,
} from "./utils/tool-wrapper.js";

describe("MCP SDK tool annotations category", () => {
it("keeps annotations.category on the wire and exposes _meta.category to SDK Client", async () => {
const server = new McpServer({
name: "category-probe-server",
version: "0.0.1",
});

const annotations: ToolAnnotations = {
readOnlyHint: true,
openWorldHint: false,
category: "env",
};

server.registerTool(
"categoryProbe",
applyCategoryAnnotationMeta({
title: "Category probe",
description: "Verifies CloudBase category annotation survives listTools",
inputSchema: {},
annotations: annotations as SdkToolAnnotations,
}),
async () => ({
content: [{ type: "text", text: "ok" }],
}),
);

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();

const wireMessages: Array<Record<string, unknown>> = [];
const originalOnMessage = clientTransport.onmessage;
clientTransport.onmessage = (message) => {
wireMessages.push(message as Record<string, unknown>);
originalOnMessage?.(message);
};

const client = new Client({ name: "category-test-client", version: "0.0.1" });

await Promise.all([
server.connect(serverTransport),
client.connect(clientTransport),
]);

try {
const listed = await client.listTools();
const probe = listed.tools.find((tool) => tool.name === "categoryProbe");

expect(probe).toBeDefined();
expect(probe?.annotations?.readOnlyHint).toBe(true);
// Official SDK Client >=1.26 strips unknown annotation keys on parse.
expect((probe?.annotations as ToolAnnotations | undefined)?.category).toBeUndefined();
expect(probe?._meta?.category).toBe("env");

const listResult = wireMessages.find((message) => {
const result = message.result as { tools?: Array<Record<string, unknown>> } | undefined;
return Array.isArray(result?.tools);
});
const wireTool = (
listResult?.result as { tools: Array<{ name: string; annotations?: ToolAnnotations }> }
).tools.find((tool) => tool.name === "categoryProbe");

expect(wireTool?.annotations?.category).toBe("env");
} finally {
await client.close();
await server.close();
}
});
});
3 changes: 3 additions & 0 deletions mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export {
getDefaultServer,
type McpServer,
type ExtendedMcpServer,
type CloudBaseRegisterToolConfig,
StdioServerTransport,
telemetryReporter,
reportToolkitLifecycle,
Expand All @@ -13,6 +14,8 @@ export {
warn
} from "./server.js";

export type { Tool, ToolAnnotations } from "./utils/tool-wrapper.js";

export type {
UploadFileParams,
ListFilesParams,
Expand Down
5 changes: 4 additions & 1 deletion mcp/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ vi.mock("./tools/permissions.js", () => ({ registerPermissionTools: mockRegister
vi.mock("./tools/logs.js", () => ({ registerLogTools: mockRegisterLogTools }));
vi.mock("./tools/agents.js", () => ({ registerAgentTools: mockRegisterAgentTools }));
vi.mock("./tools/apps.js", () => ({ registerAppTools: mockRegisterAppTools }));
vi.mock("./utils/tool-wrapper.js", () => ({ wrapServerWithTelemetry: vi.fn() }));
vi.mock("./utils/tool-wrapper.js", () => ({
wrapServerWithTelemetry: vi.fn(),
applyCategoryAnnotationMeta: (config: unknown) => config,
}));
vi.mock("./utils/cloud-mode.js", () => ({
enableCloudMode: vi.fn(),
isCloudMode: vi.fn(() => false),
Expand Down
38 changes: 31 additions & 7 deletions mcp/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { McpServer, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerDatabaseTools } from "./tools/databaseNoSQL.js";
import { registerPGDatabaseTools } from "./tools/databasePG.js";
import { registerSQLDatabaseTools } from "./tools/databaseSQL.js";
Expand Down Expand Up @@ -27,7 +27,7 @@ import { enableCloudMode } from "./utils/cloud-mode.js";
import { info } from './utils/logger.js';
import { isInternationalRegion } from "./utils/tencent-cloud.js";
import { buildJsonToolResult, isToolPayloadError } from "./utils/tool-result.js";
import { wrapServerWithTelemetry } from "./utils/tool-wrapper.js";
import { wrapServerWithTelemetry, applyCategoryAnnotationMeta, type ToolAnnotations } from "./utils/tool-wrapper.js";

// 插件定义
interface PluginDefinition {
Expand Down Expand Up @@ -172,18 +172,41 @@ function parseEnabledPlugins(
return enabledPlugins;
}

// 扩展 McpServer 类型以包含 cloudBaseOptions 和新的registerTool方法
/**
* registerTool config with CloudBase `annotations.category` retained after
* MCP SDK >=1.26 closed ToolAnnotations to strip unknown keys at the type level.
*/
export type CloudBaseRegisterToolConfig = {
title?: string;
description?: string;
inputSchema?: any;
outputSchema?: any;
annotations?: ToolAnnotations;
_meta?: Record<string, unknown>;
};

// Extend McpServer with CloudBase options and category-aware registerTool.
export interface ExtendedMcpServer extends McpServer {
cloudBaseOptions?: CloudBaseOptions;
authOptions?: AuthOptions;
ide?: string;
logger?: Logger;
enabledPlugins?: string[];
pluginOptions?: PluginOptions;
/** 已注册工具的列表,供外部(如微信 IDE)提取并注册到自己的 MCP server */
/** Registered tools for external hosts (e.g. WeChat IDE) to re-register. */
toolDefs: Array<{ name: string; description: string; inputSchema: any; handler: (input: any) => Promise<any> }>;

setLogger(logger: Logger): void;

/**
* Same as MCP SDK registerTool, but annotations may include CloudBase `category`.
* Handler typing stays intentionally loose to match existing tool call sites.
*/
registerTool(
name: string,
config: CloudBaseRegisterToolConfig,
cb: (...args: any[]) => any,
): RegisteredTool;
}

/**
Expand Down Expand Up @@ -257,14 +280,15 @@ export async function createCloudBaseMcpServer(options?: {

const originalRegisterTool = server.registerTool.bind(server);
server.registerTool = ((name: string, meta: any, handler: (args: any) => Promise<any>) => {
const toolMeta = applyCategoryAnnotationMeta(meta ?? {});
// 同步记录到 toolDefs
server.toolDefs.push({
name,
description: meta?.description ?? meta?.title ?? '',
inputSchema: meta?.inputSchema ?? {},
description: toolMeta?.description ?? toolMeta?.title ?? '',
inputSchema: toolMeta?.inputSchema ?? {},
handler,
});
return originalRegisterTool(name, meta, async (args: any) => {
return originalRegisterTool(name, toolMeta, async (args: any) => {
try {
return await handler(args);
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion mcp/src/tools/functions-override.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ vi.mock("../auth.js", () => ({
logout: vi.fn(),
}));

// Mock telemetry
// Mock telemetry (identity stub keeps registerTool category dual-write working)
vi.mock("../utils/tool-wrapper.js", () => ({
wrapServerWithTelemetry: vi.fn(),
applyCategoryAnnotationMeta: (config: unknown) => config,
}));

vi.mock("../utils/cloud-mode.js", () => ({
Expand Down
6 changes: 4 additions & 2 deletions mcp/src/utils/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ class TelemetryReporter {
const nodeVersion = process.version; // Node.js版本
const arch = os.arch(); // 系统架构

// 从构建时注入的版本号获取MCP版本信息
const mcpVersion = process.env.npm_package_version || __MCP_VERSION__ || 'unknown';
// Build-time injected version; guard for vitest / non-webpack runtimes.
const mcpVersion =
process.env.npm_package_version ||
(typeof __MCP_VERSION__ !== "undefined" ? __MCP_VERSION__ : "unknown");

return {
userAgent: `${osType} ${osRelease} ${arch} ${nodeVersion} CloudBase-MCP/${mcpVersion}`,
Expand Down
56 changes: 51 additions & 5 deletions mcp/src/utils/tool-wrapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { ToolAnnotations as SdkToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
import os from 'os';
import { getCachedEnvId, getEnvId } from '../cloudbase-manager.js';
import { ExtendedMcpServer } from "../server.js";
Expand All @@ -14,8 +15,53 @@ import { isToolPayloadError } from "./tool-result.js";
* 自动记录工具调用的成功/失败状态、执行时长等信息
*/

// 重新导出 MCP SDK 的类型,方便其他模块使用
export type { Tool, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
// Re-export MCP SDK Tool type for other modules.
export type { Tool } from "@modelcontextprotocol/sdk/types.js";

/**
* CloudBase extends MCP ToolAnnotations with a stable `category` hint used by
* IDE UIs for grouping. SDK >=1.26 types annotations as a closed/strip object,
* so we keep category via an intersection while still passing it through at
* runtime (listTools returns registered annotations without schema stripping).
*/
export type ToolAnnotations = SdkToolAnnotations & {
category?: string;
};

type ToolConfigWithCategory = {
annotations?: ToolAnnotations;
_meta?: Record<string, unknown>;
[key: string]: unknown;
};

/**
* Mirror annotations.category into _meta.category.
*
* MCP SDK Client >=1.26 parses tools/list with a stripping ToolAnnotationsSchema,
* which drops unknown annotation keys. `_meta` remains available to clients, while
* the server wire payload still includes annotations.category for non-SDK hosts.
*/
export function applyCategoryAnnotationMeta<T extends ToolConfigWithCategory>(config: T): T {
const category = config?.annotations?.category;
if (typeof category !== "string" || category.length === 0) {
return config;
}

const existingMeta =
config._meta && typeof config._meta === "object" ? config._meta : undefined;

if (existingMeta && existingMeta.category === category) {
return config;
}

return {
...config,
_meta: {
...existingMeta,
category,
},
};
}

// 构建时注入的版本号
declare const __MCP_VERSION__: string;
Expand Down Expand Up @@ -74,7 +120,7 @@ ${envIdSection}
## 环境信息
- 操作系统: ${os.type()} ${os.release()}
- Node.js版本: ${process.version}
- MCP 版本:${process.env.npm_package_version || __MCP_VERSION__ || 'unknown'}
- MCP 版本:${process.env.npm_package_version || (typeof __MCP_VERSION__ !== 'undefined' ? __MCP_VERSION__ : 'unknown')}
- 系统架构: ${os.arch()}
- 时间: ${new Date().toISOString()}
- 请求ID: ${requestId}
Expand Down Expand Up @@ -274,10 +320,10 @@ export function wrapServerWithTelemetry(server: McpServer): void {
}

// Use the wrapped handler, passing the server instance
const wrappedHandler = createWrappedHandler(toolName, handler, server as ExtendedMcpServer);
const wrappedHandler = createWrappedHandler(toolName, handler, server as unknown as ExtendedMcpServer);

// Call the original registerTool method
return originalRegisterTool(toolName, toolConfig, wrappedHandler);
return originalRegisterTool(toolName, applyCategoryAnnotationMeta(toolConfig ?? {}), wrappedHandler);
};
}

Expand Down
Loading
Loading