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
43 changes: 38 additions & 5 deletions src/main/java/run/halo/mcpserver/McpToolRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.json.schema.jackson3.DefaultJsonSchemaValidator;
import io.modelcontextprotocol.spec.McpSchema;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
Expand All @@ -16,6 +20,7 @@
import org.springframework.util.ClassUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
Expand Down Expand Up @@ -189,7 +194,7 @@ private String validateProviderTools(List<McpToolDefinition> definitions) {
}

private Mono<String> verifiedProviderOwner(McpToolProvider provider, String pluginName) {
final java.net.URI providerLocation;
final URI providerLocation;
try {
var providerClass = ClassUtils.getUserClass(provider);
var codeSource = providerClass.getProtectionDomain().getCodeSource();
Expand All @@ -204,14 +209,38 @@ private Mono<String> verifiedProviderOwner(McpToolProvider provider, String plug
}
return extensionClient.fetch(Plugin.class, pluginName)
.filter(plugin -> plugin.getStatus() != null
&& plugin.getStatus().getLoadLocation() != null
&& providerLocation.equals(plugin.getStatus().getLoadLocation().normalize()))
&& plugin.getStatus().getLoadLocation() != null)
.flatMap(plugin -> ownsProvider(
plugin.getStatus().getLoadLocation(), providerLocation))
.filter(Boolean::booleanValue)
.map(ignored -> pluginName)
.switchIfEmpty(Mono.error(new McpToolException(
"INVALID_TOOL_NAME",
"Contributed tool namespace does not match its provider plugin")));
}

static Mono<Boolean> ownsProvider(URI pluginLocation, URI providerLocation) {
var normalizedPlugin = pluginLocation.normalize();
var normalizedProvider = providerLocation.normalize();
if (normalizedPlugin.equals(normalizedProvider)) {
return Mono.just(true);
}
if (!"file".equalsIgnoreCase(normalizedPlugin.getScheme())
|| !"file".equalsIgnoreCase(normalizedProvider.getScheme())) {
return Mono.just(false);
}
return Mono.fromCallable(() -> {
try {
var pluginPath = Path.of(normalizedPlugin).toRealPath();
var providerPath = Path.of(normalizedProvider).toRealPath();
return Files.isDirectory(pluginPath) && providerPath.startsWith(pluginPath);
} catch (IOException | IllegalArgumentException error) {
return false;
}
})
.subscribeOn(Schedulers.boundedElastic());
}

private void validateSchema(String toolName, String kind, Map<String, Object> schema) {
var validation = schemaValidator.validateSchema(schema);
if (!validation.valid()) {
Expand Down Expand Up @@ -242,8 +271,12 @@ private McpSchema.CallToolResult result(McpToolResult result) {
var builder = McpSchema.CallToolResult.builder()
.structuredContent(result.structuredContent())
.isError(result.error());
if (result.textContent() != null) {
builder.addTextContent(result.textContent());
var textContent = result.textContent();
if (textContent == null && result.structuredContent() != null) {
textContent = JsonMapper.shared().writeValueAsString(result.structuredContent());
Comment thread
ruibaby marked this conversation as resolved.
}
if (textContent != null) {
builder.addTextContent(textContent);
}
return builder.build();
}
Expand Down
3 changes: 2 additions & 1 deletion src/test/java/run/halo/mcpserver/HaloMcpServerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ void listsAndCallsAContributedToolDirectly() {
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.result.structuredContent.message").isEqualTo("Hello Halo");
.jsonPath("$.result.structuredContent.message").isEqualTo("Hello Halo")
.jsonPath("$.result.content[0].text").isEqualTo("{\"message\":\"Hello Halo\"}");

var page = recentCallHistory.list(new McpRecentCallQuery(1, 20, null, null, null));
org.assertj.core.api.Assertions.assertThat(page.total()).isEqualTo(1);
Expand Down
53 changes: 52 additions & 1 deletion src/test/java/run/halo/mcpserver/McpToolRegistryTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.Metadata;
Expand Down Expand Up @@ -56,7 +60,8 @@ void executesAValidatedContributedTool() {
.annotations(McpToolAnnotations.readOnly("Hello"))
.permission(invocation -> Mono.just(true))
.handler(invocation -> Mono.just(McpToolResult.success(
Map.of("message", "Hello " + invocation.arguments().get("name")))))
Map.of("message", "Hello " + invocation.arguments().get("name")),
"Custom greeting")))
.build();
providerTools(provider, "demo", tool);

Expand All @@ -65,6 +70,10 @@ void executesAValidatedContributedTool() {
.assertNext(result -> {
assertThat(result).isPresent();
assertThat(result.orElseThrow().structuredContent().toString()).contains("Hello Halo");
assertThat(result.orElseThrow().content().getFirst())
.isInstanceOfSatisfying(
io.modelcontextprotocol.spec.McpSchema.TextContent.class,
content -> assertThat(content.text()).isEqualTo("Custom greeting"));
})
.verifyComplete();
}
Expand Down Expand Up @@ -232,6 +241,48 @@ void usesTheOwningPluginAsTheRequiredNamespace() {
.verifyComplete();
}

@Test
void acceptsAProviderLoadedFromItsDevelopmentPluginDirectory() throws Exception {
McpToolProvider developmentProvider = () -> Flux.just(tool("demo/hello"));
var providerLocation = developmentProvider.getClass()
.getProtectionDomain()
.getCodeSource()
.getLocation()
.toURI()
.normalize();
var plugin = new Plugin();
var metadata = new Metadata();
metadata.setName("demo");
plugin.setMetadata(metadata);
var status = new Plugin.PluginStatus();
status.setLoadLocation(Path.of(providerLocation).getParent().toUri());
plugin.setStatus(status);
when(extensionGetter.getEnabledExtensions(McpToolProvider.class))
.thenReturn(Flux.just(developmentProvider));
when(extensionClient.fetch(Plugin.class, "demo")).thenReturn(Mono.just(plugin));

StepVerifier.create(Mono.defer(registry::registeredTools)
.subscribeOn(Schedulers.parallel()))
.assertNext(tools -> {
assertThat(tools)
.extracting(tool -> tool.definition().name())
.containsExactly("demo/hello");
assertThat(Schedulers.isInNonBlockingThread()).isFalse();
})
.verifyComplete();
}

@Test
void rejectsAProviderLoadedFromANeighboringDevelopmentDirectory(@TempDir Path tempDir)
throws Exception {
var pluginDirectory = Files.createDirectory(tempDir.resolve("plugin"));
var neighboringDirectory = Files.createDirectory(tempDir.resolve("plugin-other"));

assertThat(McpToolRegistry.ownsProvider(
pluginDirectory.toUri(), neighboringDirectory.toUri()).block())
.isFalse();
}

@Test
void quarantinesInvalidSchemasAndConflictingTools() {
var invalidSchema = McpToolDefinition.builder()
Expand Down