From e7b7af71009f4c6f81a0200b5e83d041b4a334b5 Mon Sep 17 00:00:00 2001 From: Ryan Wang Date: Fri, 4 Sep 2026 16:47:05 +0800 Subject: [PATCH 1/3] Add automatic all-tools access for MCP keys --- .../mcpserver/AuthorizedMcpTransport.java | 4 +-- .../halo/mcpserver/McpAccessKeyEndpoint.java | 1 + .../run/halo/mcpserver/McpAuthorization.java | 5 --- .../mcpserver/McpKeyAuthenticationToken.java | 4 ++- .../mcpserver/AuthorizedMcpTransportTest.java | 30 ++++++++++++++-- .../mcpserver/McpAccessKeyEndpointTest.java | 36 +++++++++++++++++++ .../mcpserver/McpAccessKeyServiceTest.java | 13 +++++++ ui/src/components/AccessKeyForm.vue | 34 ++++++++++++++---- ui/src/components/AccessKeyListItem.vue | 8 ++++- .../__tests__/AccessKeyForm.test.ts | 34 +++++++++++++++++- .../__tests__/AccessKeyListItem.test.ts | 7 ++++ 11 files changed, 157 insertions(+), 19 deletions(-) diff --git a/src/main/java/run/halo/mcpserver/AuthorizedMcpTransport.java b/src/main/java/run/halo/mcpserver/AuthorizedMcpTransport.java index 5353fa2..3e2e0c2 100644 --- a/src/main/java/run/halo/mcpserver/AuthorizedMcpTransport.java +++ b/src/main/java/run/halo/mcpserver/AuthorizedMcpTransport.java @@ -69,10 +69,10 @@ public Mono handleNotification( } private Mono listTools(McpSchema.JSONRPCRequest request) { - return authorization.allowedTools() + return authorization.authentication() .zipWith(catalog.protocolTools()) .map(tuple -> tuple.getT2().stream() - .filter(tool -> tuple.getT1().contains(tool.name())) + .filter(tool -> tuple.getT1().allows(tool.name())) .toList()) .map(tools -> McpSchema.JSONRPCResponse.result( request.id(), McpSchema.ListToolsResult.builder(tools).build())) diff --git a/src/main/java/run/halo/mcpserver/McpAccessKeyEndpoint.java b/src/main/java/run/halo/mcpserver/McpAccessKeyEndpoint.java index c979959..a8c4572 100644 --- a/src/main/java/run/halo/mcpserver/McpAccessKeyEndpoint.java +++ b/src/main/java/run/halo/mcpserver/McpAccessKeyEndpoint.java @@ -255,6 +255,7 @@ private Mono validateTools(Set requestedTools, Set allowed var requested = tools(requestedTools); return toolCatalog.availableNames().flatMap(available -> { var unknown = new LinkedHashSet<>(requested); + unknown.remove(McpKeyAuthenticationToken.ALL_TOOLS); unknown.removeAll(available); unknown.removeAll(allowedUnavailableTools); if (!unknown.isEmpty()) { diff --git a/src/main/java/run/halo/mcpserver/McpAuthorization.java b/src/main/java/run/halo/mcpserver/McpAuthorization.java index e6e374b..f5e438c 100644 --- a/src/main/java/run/halo/mcpserver/McpAuthorization.java +++ b/src/main/java/run/halo/mcpserver/McpAuthorization.java @@ -1,6 +1,5 @@ package run.halo.mcpserver; -import java.util.Set; import java.util.function.Supplier; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; @@ -48,8 +47,4 @@ Mono authentication() { public Mono username() { return authentication().map(McpKeyAuthenticationToken::getName); } - - Mono> allowedTools() { - return authentication().map(McpKeyAuthenticationToken::allowedTools); - } } diff --git a/src/main/java/run/halo/mcpserver/McpKeyAuthenticationToken.java b/src/main/java/run/halo/mcpserver/McpKeyAuthenticationToken.java index fa92176..c556376 100644 --- a/src/main/java/run/halo/mcpserver/McpKeyAuthenticationToken.java +++ b/src/main/java/run/halo/mcpserver/McpKeyAuthenticationToken.java @@ -7,6 +7,8 @@ final class McpKeyAuthenticationToken extends AbstractAuthenticationToken { + static final String ALL_TOOLS = "*"; + private final String keyId; private final String keyDisplayName; private final String keyPrefix; @@ -45,7 +47,7 @@ Set allowedTools() { } boolean allows(String toolName) { - return allowedTools.contains(toolName); + return allowedTools.contains(ALL_TOOLS) || allowedTools.contains(toolName); } @Override diff --git a/src/test/java/run/halo/mcpserver/AuthorizedMcpTransportTest.java b/src/test/java/run/halo/mcpserver/AuthorizedMcpTransportTest.java index 00baaaa..ba81702 100644 --- a/src/test/java/run/halo/mcpserver/AuthorizedMcpTransportTest.java +++ b/src/test/java/run/halo/mcpserver/AuthorizedMcpTransportTest.java @@ -11,6 +11,7 @@ import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper; import io.modelcontextprotocol.server.McpStatelessServerHandler; import io.modelcontextprotocol.spec.McpSchema; +import java.util.List; import java.util.Map; import java.util.Set; import org.junit.jupiter.api.Test; @@ -51,6 +52,28 @@ void sanitizesInternalErrorsFromBuiltInToolCalls() { assertInternalErrorIsSanitized(response, 2); } + @Test + void wildcardListsEveryCurrentlyAvailableTool() { + var fixture = fixture(); + var first = McpSchema.Tool.builder("halo_first", Map.of("type", "object")).build(); + var addedLater = McpSchema.Tool.builder("PluginExample__added_later", Map.of("type", "object")) + .build(); + when(fixture.catalog().protocolTools()).thenReturn(Mono.just(List.of(first, addedLater))); + var request = new McpSchema.JSONRPCRequest("tools/list", 3, Map.of()); + var authentication = new McpKeyAuthenticationToken( + "key-id", "Automation", "hmcp_key", "admin", Set.of("*")); + + var response = fixture.handler() + .handleRequest(McpTransportContext.EMPTY, request) + .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)) + .block(); + + assertThat(response.result()).isInstanceOf(McpSchema.ListToolsResult.class); + var result = (McpSchema.ListToolsResult) response.result(); + assertThat(result.tools()).extracting(McpSchema.Tool::name) + .containsExactly("halo_first", "PluginExample__added_later"); + } + @Test void acceptsInitializedNotificationWithoutDelegatingToMissingSdkHandler() { var fixture = fixture(); @@ -99,7 +122,7 @@ private static Fixture fixture() { transport.setMcpHandler(sdkHandler); var captor = ArgumentCaptor.forClass(McpStatelessServerHandler.class); verify(delegate).setMcpHandler(captor.capture()); - return new Fixture(captor.getValue(), sdkHandler); + return new Fixture(captor.getValue(), sdkHandler, catalog); } private static void assertInternalErrorIsSanitized( @@ -112,5 +135,8 @@ private static void assertInternalErrorIsSanitized( assertThat(response.toString()).doesNotContain("database password"); } - private record Fixture(McpStatelessServerHandler handler, McpStatelessServerHandler sdkHandler) {} + private record Fixture( + McpStatelessServerHandler handler, + McpStatelessServerHandler sdkHandler, + McpToolCatalog catalog) {} } diff --git a/src/test/java/run/halo/mcpserver/McpAccessKeyEndpointTest.java b/src/test/java/run/halo/mcpserver/McpAccessKeyEndpointTest.java index b07f37d..31ac02c 100644 --- a/src/test/java/run/halo/mcpserver/McpAccessKeyEndpointTest.java +++ b/src/test/java/run/halo/mcpserver/McpAccessKeyEndpointTest.java @@ -139,6 +139,42 @@ void preservesExistingUnavailableToolsButRejectsNewUnknownTools() { .isBadRequest(); } + @Test + void acceptsTheAllToolsWildcard() { + when(toolCatalog.availableNames()).thenReturn(reactor.core.publisher.Mono.just(Set.of())); + when(accessKeyService.allowedTools("test-key")) + .thenReturn(reactor.core.publisher.Mono.just(Set.of())); + var updated = new McpAccessKey(); + updated.setMetadata(new Metadata()); + updated.getMetadata().setName("test-key"); + updated.getSpec().setAllowedTools(Set.of("*")); + when(accessKeyService.update(any(), any(), any(), any(), any(), anyBoolean())) + .thenReturn(reactor.core.publisher.Mono.just(updated)); + + WebTestClient.bindToRouterFunction(endpoint.endpoint()) + .build() + .put() + .uri("/keys/test-key") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "displayName": "Test key", + "allowedTools": ["*"], + "allowedIpRanges": [], + "enabled": true + } + """) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("$.allowedTools[0]") + .isEqualTo("*"); + + verify(accessKeyService).update( + "test-key", "Test key", Set.of("*"), Set.of(), null, true); + } + @Test void listsRecentCallsWithFilters() { var call = new McpRecentCall( diff --git a/src/test/java/run/halo/mcpserver/McpAccessKeyServiceTest.java b/src/test/java/run/halo/mcpserver/McpAccessKeyServiceTest.java index 840c18f..bee9412 100644 --- a/src/test/java/run/halo/mcpserver/McpAccessKeyServiceTest.java +++ b/src/test/java/run/halo/mcpserver/McpAccessKeyServiceTest.java @@ -64,6 +64,19 @@ void createsAHashedKeyAndAuthenticatesIt() { assertThat(created.accessKey().getStatus().getLastUsedAt()).isNotNull(); } + @Test + void wildcardAllowsCurrentAndFutureTools() { + var created = service.create("Automation", "admin", Set.of("*"), Set.of(), null).block(); + when(client.fetch(McpAccessKey.class, created.accessKey().getMetadata().getName())) + .thenReturn(Mono.just(created.accessKey())); + + var authentication = service.authenticate(created.token(), null).block(); + + assertThat(authentication).isNotNull(); + assertThat(authentication.allows("halo_search_content")).isTrue(); + assertThat(authentication.allows("FuturePlugin__future_tool")).isTrue(); + } + @Test void rejectsDisabledAndExpiredKeys() { var created = service.create( diff --git a/ui/src/components/AccessKeyForm.vue b/ui/src/components/AccessKeyForm.vue index d4dade5..34e111d 100644 --- a/ui/src/components/AccessKeyForm.vue +++ b/ui/src/components/AccessKeyForm.vue @@ -3,7 +3,7 @@ import type { McpAccessKey, McpTool, UpdateMcpAccessKeyRequest } from '@/api' import McpToolCard from '@/components/McpToolCard.vue' import { groupTools } from '@/utils/tool' import { submitForm } from '@formkit/core' -import { VButton, VSpace, VTag } from '@halo-dev/components' +import { VAlert, VButton, VSpace, VTag } from '@halo-dev/components' import { utils } from '@halo-dev/ui-shared' import { computed, reactive, shallowRef } from 'vue' @@ -16,6 +16,7 @@ const emit = defineEmits<{ submit: [input: UpdateMcpAccessKeyRequest] }>() +const ALL_TOOLS = '*' const formId = `mcp-access-key-form-${props.accessKey?.name ?? 'new'}` const displayName = shallowRef(props.accessKey?.displayName ?? '') const expiresAt = shallowRef( @@ -23,6 +24,7 @@ const expiresAt = shallowRef( ) const enabled = shallowRef(props.accessKey?.enabled ?? true) const allowedIpRanges = shallowRef((props.accessKey?.allowedIpRanges ?? []).join('\n')) +const allowAllTools = shallowRef(props.accessKey?.allowedTools.includes(ALL_TOOLS) ?? false) const selected = reactive>( Object.fromEntries( props.tools.map((tool) => [ @@ -35,7 +37,9 @@ const selected = reactive>( const groups = computed(() => groupTools(props.tools)) const availableNames = new Set(props.tools.map((tool) => tool.name)) const unavailableAllowedTools = shallowRef( - (props.accessKey?.allowedTools ?? []).filter((name) => !availableNames.has(name)), + (props.accessKey?.allowedTools ?? []).filter( + (name) => name !== ALL_TOOLS && !availableNames.has(name), + ), ) const selectedCount = computed(() => props.tools.filter((tool) => selected[tool.name]).length) @@ -67,10 +71,12 @@ function onSubmit() { .filter(Boolean), ), ], - allowedTools: [ - ...unavailableAllowedTools.value, - ...props.tools.filter((tool) => selected[tool.name]).map((tool) => tool.name), - ], + allowedTools: allowAllTools.value + ? [ALL_TOOLS] + : [ + ...unavailableAllowedTools.value, + ...props.tools.filter((tool) => selected[tool.name]).map((tool) => tool.name), + ], expiresAt: expiresAt.value ? utils.date.toISOString(expiresAt.value) : undefined, enabled: enabled.value, }) @@ -108,8 +114,22 @@ defineExpose({ :placeholder="'203.0.113.10\n203.0.113.0/24\n2001:db8::/32'" :rows="5" /> + + -
+
diff --git a/ui/src/components/AccessKeyListItem.vue b/ui/src/components/AccessKeyListItem.vue index 8ee659e..13b8b7b 100644 --- a/ui/src/components/AccessKeyListItem.vue +++ b/ui/src/components/AccessKeyListItem.vue @@ -113,7 +113,13 @@ function handleDelete() {