Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
14 changes: 14 additions & 0 deletions api/src/main/java/run/halo/mcpserver/api/McpToolDefinition.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> inputSchema;
private final Map<String, Object> outputSchema;
private final McpToolAnnotations annotations;
Expand All @@ -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.strip();
Comment thread
ruibaby marked this conversation as resolved.
this.inputSchema = copySchema(builder.inputSchema);
this.outputSchema = builder.outputSchema == null ? null : copySchema(builder.outputSchema);
this.annotations = Objects.requireNonNullElse(
Expand Down Expand Up @@ -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<String, Object> inputSchema() {
return inputSchema;
}
Expand Down Expand Up @@ -100,6 +108,7 @@ public static final class Builder {
private String description;
private String displayTitle;
private String displayDescription;
private String category;
private Map<String, Object> inputSchema;
private Map<String, Object> outputSchema;
private McpToolAnnotations annotations;
Expand Down Expand Up @@ -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<String, Object> inputSchema) {
this.inputSchema = inputSchema;
return this;
Expand Down
12 changes: 12 additions & 0 deletions dev/dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 类名作为启用条件。必须使用字符串形式,因为
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/run/halo/mcpserver/McpToolCatalog.java
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ private static ToolDescriptor descriptor(RegisteredTool tool, ToolSource source)
definition.displayDescription(),
definition.inputSchema(),
definition.outputSchema(),
"PLUGIN",
definition.category(),
Comment thread
ruibaby marked this conversation as resolved.
definition.annotations().readOnlyHint(),
definition.annotations().destructiveHint(),
true,
Expand Down
31 changes: 31 additions & 0 deletions src/test/java/run/halo/mcpserver/McpToolCatalogTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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");
});
Expand All @@ -107,6 +109,35 @@ void usesProviderDisplayCopyOnlyForTheConsoleCatalog() {
});
}

@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.NullAndEmptySource
@org.junit.jupiter.params.provider.ValueSource(strings = {" ", "\u3000", "\u2000\u2003"})
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");
}

@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)
.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(
Expand Down
4 changes: 3 additions & 1 deletion ui/src/components/McpToolDetailsModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ const modal = useTemplateRef<InstanceType<typeof VModal>>('modal')
</div>
<div>
<dt class=":uno: text-xs text-gray-500">分类</dt>
<dd class=":uno: mt-1 text-sm text-gray-800">{{ toolCategoryLabel(tool.category) }}</dd>
<dd class=":uno: mt-1 text-sm text-gray-800">
{{ toolCategoryLabel(tool.category, tool.source.type) }}
</dd>
</div>
</dl>

Expand Down
13 changes: 13 additions & 0 deletions ui/src/components/__tests__/McpToolDetailsModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ function mountModal(outputSchema?: Record<string, unknown>) {
}

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' } } })

Expand Down
31 changes: 31 additions & 0 deletions ui/src/utils/__tests__/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,37 @@ 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'),
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'),
Expand Down
7 changes: 5 additions & 2 deletions ui/src/utils/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ const categoryLabels: Record<string, string> = {
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
}

Expand Down Expand Up @@ -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(
Expand Down
Loading