From e93def020d9068da441dd89d544e504686268ebb Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Sat, 10 Oct 2026 17:47:39 +0800 Subject: [PATCH 1/2] Support custom categories for plugin-contributed MCP tools --- .../halo/mcpserver/api/McpToolDefinition.java | 14 ++++++++++ dev/dev.md | 12 ++++++++ .../run/halo/mcpserver/McpToolCatalog.java | 2 +- .../halo/mcpserver/McpToolCatalogTest.java | 28 +++++++++++++++++++ ui/src/utils/__tests__/tool.test.ts | 22 +++++++++++++++ 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/api/src/main/java/run/halo/mcpserver/api/McpToolDefinition.java b/api/src/main/java/run/halo/mcpserver/api/McpToolDefinition.java index 0116328..351c7be 100644 --- a/api/src/main/java/run/halo/mcpserver/api/McpToolDefinition.java +++ b/api/src/main/java/run/halo/mcpserver/api/McpToolDefinition.java @@ -12,6 +12,7 @@ public final class McpToolDefinition { private final String description; private final String displayTitle; private final String displayDescription; + private final String category; private final Map inputSchema; private final Map outputSchema; private final McpToolAnnotations annotations; @@ -32,6 +33,8 @@ private McpToolDefinition(Builder builder) { this.description = Objects.requireNonNullElse(builder.description, ""); this.displayTitle = builder.displayTitle == null ? this.title : builder.displayTitle; this.displayDescription = Objects.requireNonNullElse(builder.displayDescription, this.description); + this.category = builder.category == null || builder.category.isBlank() + ? "PLUGIN" : builder.category.trim(); this.inputSchema = copySchema(builder.inputSchema); this.outputSchema = builder.outputSchema == null ? null : copySchema(builder.outputSchema); this.annotations = Objects.requireNonNullElse( @@ -70,6 +73,11 @@ public String displayDescription() { return displayDescription; } + /** Business category within the plugin in Halo's management UI. Defaults to PLUGIN. */ + public String category() { + return category; + } + public Map inputSchema() { return inputSchema; } @@ -100,6 +108,7 @@ public static final class Builder { private String description; private String displayTitle; private String displayDescription; + private String category; private Map inputSchema; private Map outputSchema; private McpToolAnnotations annotations; @@ -131,6 +140,11 @@ public Builder displayDescription(String displayDescription) { return this; } + public Builder category(String category) { + this.category = category; + return this; + } + public Builder inputSchema(Map inputSchema) { this.inputSchema = inputSchema; return this; diff --git a/dev/dev.md b/dev/dev.md index eae1a1f..e0c89c5 100644 --- a/dev/dev.md +++ b/dev/dev.md @@ -107,6 +107,7 @@ public final class ExportToolProvider implements McpToolProvider { .description("Export plugin data.") .displayTitle("导出数据") .displayDescription("导出插件数据。") + .category("数据管理") .inputSchema(Map.of( "type", "object", "properties", Map.of(), @@ -134,6 +135,17 @@ Provider 只声明本地 `snake_case` 名称。MCP Server 会根据 Provider 的 和 `displayDescription` 只用于 Halo 的 MCP 管理界面,因此插件可以提供中文管理文案,而不 改变协议描述;省略时,管理界面会回退到协议字段。 +### 插件内工具分类 + +可通过 `.category("数据管理")` 设置工具在插件内的业务分类。管理页面与访问密钥的工具选择器 +均按「所属插件 → 分类」展示;自定义分类字符串直接作为显示名称,可使用中文。 +省略、空字符串或纯空白值使用默认分类 `PLUGIN`(界面显示「插件工具」),自定义值会去除首尾空白。 +同一插件的多个 Provider 可以使用相同分类;仅拆分 Provider 不会自动生成分类。 + +分类只影响管理界面,不改变协议工具名、输入输出 Schema 或访问密钥的逐工具授权。 +此字段属于当前开发分支新增 API,已发布的 `api:1.0.0` 不包含它;使用分类的 Provider +需要搭配包含该字段的 MCP Server,并使用本地 API JAR 或包含该改动的 SNAPSHOT 编译。 + ## 按条件注册 Provider 通过配置类注册 Provider Bean,并用 API 类名作为启用条件。必须使用字符串形式,因为 diff --git a/src/main/java/run/halo/mcpserver/McpToolCatalog.java b/src/main/java/run/halo/mcpserver/McpToolCatalog.java index 1e7f9f7..22af8ec 100644 --- a/src/main/java/run/halo/mcpserver/McpToolCatalog.java +++ b/src/main/java/run/halo/mcpserver/McpToolCatalog.java @@ -128,7 +128,7 @@ private static ToolDescriptor descriptor(RegisteredTool tool, ToolSource source) definition.displayDescription(), definition.inputSchema(), definition.outputSchema(), - "PLUGIN", + definition.category(), definition.annotations().readOnlyHint(), definition.annotations().destructiveHint(), true, diff --git a/src/test/java/run/halo/mcpserver/McpToolCatalogTest.java b/src/test/java/run/halo/mcpserver/McpToolCatalogTest.java index 87e06d0..0a8c5fb 100644 --- a/src/test/java/run/halo/mcpserver/McpToolCatalogTest.java +++ b/src/test/java/run/halo/mcpserver/McpToolCatalogTest.java @@ -78,6 +78,7 @@ void usesProviderDisplayCopyOnlyForTheConsoleCatalog() { .description("Export plugin data.") .displayTitle("导出数据") .displayDescription("导出插件数据。") + .category("数据管理") .inputSchema(Map.of("type", "object", "properties", Map.of())) .outputSchema(Map.of( "type", "object", @@ -97,6 +98,7 @@ void usesProviderDisplayCopyOnlyForTheConsoleCatalog() { assertThat(tool.name()).isEqualTo("demo__export"); assertThat(tool.title()).isEqualTo("导出数据"); assertThat(tool.description()).isEqualTo("导出插件数据。"); + assertThat(tool.category()).isEqualTo("数据管理"); assertThat(tool.inputSchema()).containsEntry("type", "object"); assertThat(tool.outputSchema()).containsEntry("type", "object"); }); @@ -107,6 +109,32 @@ void usesProviderDisplayCopyOnlyForTheConsoleCatalog() { }); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.NullAndEmptySource + @org.junit.jupiter.params.provider.ValueSource(strings = {" "}) + void defaultsUnspecifiedCategoriesToPlugin(String category) { + var definition = McpToolDefinition.builder() + .name("export") + .category(category) + .inputSchema(Map.of("type", "object", "properties", Map.of())) + .permission(invocation -> Mono.just(true)) + .handler(invocation -> Mono.empty()) + .build(); + assertThat(definition.category()).isEqualTo("PLUGIN"); + } + + @Test + void trimsCustomCategory() { + var definition = McpToolDefinition.builder() + .name("export") + .category(" 数据管理 ") + .inputSchema(Map.of("type", "object", "properties", Map.of())) + .permission(invocation -> Mono.just(true)) + .handler(invocation -> Mono.empty()) + .build(); + assertThat(definition.category()).isEqualTo("数据管理"); + } + @Test void keepsBuiltInToolsWhenContributedToolDiscoveryFails() { var protocolTool = McpSchema.Tool.builder( diff --git a/ui/src/utils/__tests__/tool.test.ts b/ui/src/utils/__tests__/tool.test.ts index 628f86c..5bc1761 100644 --- a/ui/src/utils/__tests__/tool.test.ts +++ b/ui/src/utils/__tests__/tool.test.ts @@ -19,6 +19,28 @@ function tool(name: string, category: string, pluginName = 'PluginMcpServer'): M } describe('groupTools', () => { + it('groups contributed tools by their custom categories within each plugin', () => { + const groups = groupTools([ + tool('PluginLinks__list_links', '友链管理', 'PluginLinks'), + tool('PluginLinks__create_link', '友链管理', 'PluginLinks'), + tool('PluginLinks__list_feed_items', '订阅阅读', 'PluginLinks'), + tool('other__list_links', '友链管理', 'OtherPlugin'), + ]) + + const links = groups.find(({ source }) => source.pluginName === 'PluginLinks') + expect(links?.toolCount).toBe(3) + expect(links?.categories).toHaveLength(2) + expect(links?.categories.find(({ category }) => category === '友链管理')).toMatchObject({ + label: '友链管理', + tools: [{ name: 'PluginLinks__list_links' }, { name: 'PluginLinks__create_link' }], + }) + expect(links?.categories.find(({ category }) => category === '订阅阅读')).toMatchObject({ + label: '订阅阅读', + tools: [{ name: 'PluginLinks__list_feed_items' }], + }) + expect(groups.find(({ source }) => source.pluginName === 'OtherPlugin')?.toolCount).toBe(1) + }) + it('groups tools by source and business category', () => { const groups = groupTools([ tool('halo_list_posts', 'POST'), From c41a741cb105fc3fa537ffdc90d9a41e214e04f2 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Sat, 10 Oct 2026 18:07:08 +0800 Subject: [PATCH 2/2] Fix custom tool category labels and whitespace normalization --- .../run/halo/mcpserver/api/McpToolDefinition.java | 2 +- .../java/run/halo/mcpserver/McpToolCatalogTest.java | 11 +++++++---- ui/src/components/McpToolDetailsModal.vue | 4 +++- .../__tests__/McpToolDetailsModal.test.ts | 13 +++++++++++++ ui/src/utils/__tests__/tool.test.ts | 9 +++++++++ ui/src/utils/tool.ts | 7 +++++-- 6 files changed, 38 insertions(+), 8 deletions(-) diff --git a/api/src/main/java/run/halo/mcpserver/api/McpToolDefinition.java b/api/src/main/java/run/halo/mcpserver/api/McpToolDefinition.java index 351c7be..ba60d15 100644 --- a/api/src/main/java/run/halo/mcpserver/api/McpToolDefinition.java +++ b/api/src/main/java/run/halo/mcpserver/api/McpToolDefinition.java @@ -34,7 +34,7 @@ private McpToolDefinition(Builder builder) { this.displayTitle = builder.displayTitle == null ? this.title : builder.displayTitle; this.displayDescription = Objects.requireNonNullElse(builder.displayDescription, this.description); this.category = builder.category == null || builder.category.isBlank() - ? "PLUGIN" : builder.category.trim(); + ? "PLUGIN" : builder.category.strip(); this.inputSchema = copySchema(builder.inputSchema); this.outputSchema = builder.outputSchema == null ? null : copySchema(builder.outputSchema); this.annotations = Objects.requireNonNullElse( diff --git a/src/test/java/run/halo/mcpserver/McpToolCatalogTest.java b/src/test/java/run/halo/mcpserver/McpToolCatalogTest.java index 0a8c5fb..a2f3287 100644 --- a/src/test/java/run/halo/mcpserver/McpToolCatalogTest.java +++ b/src/test/java/run/halo/mcpserver/McpToolCatalogTest.java @@ -111,7 +111,7 @@ void usesProviderDisplayCopyOnlyForTheConsoleCatalog() { @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.NullAndEmptySource - @org.junit.jupiter.params.provider.ValueSource(strings = {" "}) + @org.junit.jupiter.params.provider.ValueSource(strings = {" ", "\u3000", "\u2000\u2003"}) void defaultsUnspecifiedCategoriesToPlugin(String category) { var definition = McpToolDefinition.builder() .name("export") @@ -123,11 +123,14 @@ void defaultsUnspecifiedCategoriesToPlugin(String category) { assertThat(definition.category()).isEqualTo("PLUGIN"); } - @Test - void trimsCustomCategory() { + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = { + " 数据管理 ", "\u3000数据管理\u3000", "\u2000数据管理\u2003" + }) + void trimsCustomCategory(String category) { var definition = McpToolDefinition.builder() .name("export") - .category(" 数据管理 ") + .category(category) .inputSchema(Map.of("type", "object", "properties", Map.of())) .permission(invocation -> Mono.just(true)) .handler(invocation -> Mono.empty()) diff --git a/ui/src/components/McpToolDetailsModal.vue b/ui/src/components/McpToolDetailsModal.vue index 30c6222..7da9379 100644 --- a/ui/src/components/McpToolDetailsModal.vue +++ b/ui/src/components/McpToolDetailsModal.vue @@ -55,7 +55,9 @@ const modal = useTemplateRef>('modal')
分类
-
{{ toolCategoryLabel(tool.category) }}
+
+ {{ toolCategoryLabel(tool.category, tool.source.type) }} +
diff --git a/ui/src/components/__tests__/McpToolDetailsModal.test.ts b/ui/src/components/__tests__/McpToolDetailsModal.test.ts index 6ce42c1..796aa97 100644 --- a/ui/src/components/__tests__/McpToolDetailsModal.test.ts +++ b/ui/src/components/__tests__/McpToolDetailsModal.test.ts @@ -51,6 +51,19 @@ function mountModal(outputSchema?: Record) { } describe('McpToolDetailsModal', () => { + it('preserves a plugin category that matches a built-in category', async () => { + const wrapper = mountModal() + const tool = wrapper.props('tool') + await wrapper.setProps({ + tool: { + ...tool, + source: { type: 'PLUGIN', pluginName: 'PluginDemo', displayName: 'Demo' }, + }, + }) + + expect(wrapper.findAll('dd')[1]?.text()).toBe('POST') + }) + it('renders diagnostic metadata and input/output schemas', () => { const wrapper = mountModal({ type: 'object', properties: { items: { type: 'array' } } }) diff --git a/ui/src/utils/__tests__/tool.test.ts b/ui/src/utils/__tests__/tool.test.ts index 5bc1761..a158929 100644 --- a/ui/src/utils/__tests__/tool.test.ts +++ b/ui/src/utils/__tests__/tool.test.ts @@ -19,6 +19,15 @@ function tool(name: string, category: string, pluginName = 'PluginMcpServer'): M } describe('groupTools', () => { + it.each(['POST', 'THEME', 'CATEGORY', 'constructor', '__proto__'])( + 'preserves the contributed category label %s', + (category) => { + const groups = groupTools([tool('demo__export', category, 'PluginDemo')]) + + expect(groups[0]?.categories[0]?.label).toBe(category) + }, + ) + it('groups contributed tools by their custom categories within each plugin', () => { const groups = groupTools([ tool('PluginLinks__list_links', '友链管理', 'PluginLinks'), diff --git a/ui/src/utils/tool.ts b/ui/src/utils/tool.ts index cb7a2a4..c40c3ac 100644 --- a/ui/src/utils/tool.ts +++ b/ui/src/utils/tool.ts @@ -14,7 +14,10 @@ const categoryLabels: Record = { PLUGIN: '插件工具', } -export function toolCategoryLabel(category: string) { +export function toolCategoryLabel(category: string, sourceType: McpTool['source']['type']) { + if (sourceType === 'PLUGIN' && category !== 'PLUGIN') { + return category + } return categoryLabels[category] ?? category } @@ -55,7 +58,7 @@ export function groupTools(tools: McpTool[]): ToolSourceGroup[] { source: group.source, categories: [...group.categories].map(([category, categoryTools]) => ({ category, - label: toolCategoryLabel(category), + label: toolCategoryLabel(category, group.source.type), tools: categoryTools, })), toolCount: [...group.categories.values()].reduce(