Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:

jobs:
cd:
uses: halo-sigs/reusable-workflows/.github/workflows/plugin-cd.yaml@v4
uses: ./.github/workflows/plugin-cd.yaml
permissions:
contents: write
with:
Expand Down
133 changes: 133 additions & 0 deletions .github/workflows/plugin-cd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: CD

on:
workflow_call:
inputs:
artifacts-path:
type: string
required: false
default: build/libs
description: Artifacts path, default is build/libs. Must be a folder.
node-version:
description: Node.js version.
type: string
required: false
default: "24"
pnpm-version:
description: pnpm version.
type: string
required: false
default: "10"
java-version:
description: Java version.
type: string
required: false
default: "21"
ui-path:
description: Path of UI project.
type: string
required: false
default: "console"
skip-node-setup:
description: Indicates if the node setup should be skipped.
type: boolean
required: false
default: false
skip-appstore-release:
description: Indicates if the appstore release should be skipped.
type: boolean
required: false
default: false
app-id:
description: Application ID from Halo App Store.
required: false
type: string
default: not-configured-app-id
halo-backend-baseurl:
description: Base URL of Halo App Store.
required: false
default: https://www.halo.run
type: string
npm-registry-url:
type: string
default: ""
description: NPM registry.
required: false
build-args:
type: string
description: Additional build arguments.
required: false
default: ""
secrets:
halo-pat:
description: Personal access token of Halo Appstore.
required: false
npm-auth-token:
description: NPM auth token.
required: false
jobs:
build:
name: Build
runs-on: ubuntu-latest
if: github.event_name == 'release'
steps:
- uses: actions/checkout@v6
- name: Setup Environment
uses: halo-sigs/reusable-workflows/plugin-setup-env@99c24709710a37e286adca6879d8d5faf46224fc # v4
with:
cache-dept-path: ${{ inputs.ui-path }}/pnpm-lock.yaml
skip-node-setup: ${{ inputs.skip-node-setup }}
node-version: ${{ inputs.node-version }}
pnpm-version: ${{ inputs.pnpm-version }}
java-version: ${{ inputs.java-version }}
npm-registry-url: ${{ inputs.npm-registry-url }}
- name: Build
run: |
version=${{ github.event.release.tag_name }}
./gradlew clean build -x check -Pversion=${version#v} ${{ inputs.build-args }}
env:
NODE_AUTH_TOKEN: ${{ secrets.npm-auth-token }}
- name: Upload Artifacts
uses: actions/upload-artifact@v7
with:
name: artifacts
path: ${{ inputs.artifacts-path }}
retention-days: 1

github-release:
name: GitHub Release
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'release'
steps:
- uses: actions/checkout@v6
- name: Download Artifacts
uses: actions/download-artifact@v8
with:
name: artifacts
path: ${{ inputs.artifacts-path }}
- name: Upload Release Assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload ${{ github.event.release.tag_name }} ${{ inputs.artifacts-path }}/*
appstore-release:
name: App Store Release
runs-on: ubuntu-latest
needs: build
if: ${{ github.event_name == 'release' && !inputs.skip-appstore-release }}
steps:
- uses: actions/checkout@v6
- name: Download Artifacts
uses: actions/download-artifact@v8
with:
name: artifacts
path: ${{ inputs.artifacts-path }}
- name: Release to App Store
uses: halo-sigs/app-store-release-action@v4
with:
github-token: ${{secrets.GITHUB_TOKEN}}
app-id: ${{ inputs.app-id }}
halo-backend-baseurl: ${{ inputs.halo-backend-baseurl }}
release-id: ${{ github.event.release.id }}
assets-dir: ${{ inputs.artifacts-path }}
halo-pat: ${{ secrets.halo-pat }}
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ Requests carrying an MCP Bearer token are limited to 600 per minute per observed
network source before key validation. This is an overall source-level ceiling and
includes successful requests. Tool calls are additionally limited to 120 per
minute for each access-key and tool pair. Limits are process-local and therefore
apply independently to each Halo replica.
apply independently to each Halo replica. Authenticated request processing has a
30-second deadline and is limited to 8 concurrent requests per key and 64 per
plugin instance. A timed-out request returns HTTP 504; a concurrency rejection
returns HTTP 429 with `Retry-After: 1`.

Use a dedicated, least-privilege key. Do not put keys in URLs, configuration
files committed to source control, shell history, or logs.
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/run/halo/mcpserver/CategoryParentMutationLock.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package run.halo.mcpserver;

import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import lombok.Data;
import lombok.EqualsAndHashCode;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;

@Data
@EqualsAndHashCode(callSuper = true)
@GVK(
group = "mcp.halo.run",
version = "v1alpha1",
kind = "CategoryParentMutationLock",
plural = "categoryparentmutationlocks",
singular = "categoryparentmutationlock")
public class CategoryParentMutationLock extends AbstractExtension {

@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private Spec spec = new Spec();

@Data
@Schema(name = "CategoryParentMutationLockSpec")
public static class Spec {
private String holder;
private Instant expiresAt;
private long generation;
}
}
2 changes: 0 additions & 2 deletions src/main/java/run/halo/mcpserver/HaloMcpServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import io.modelcontextprotocol.server.McpStatelessAsyncServer;
import io.modelcontextprotocol.server.transport.DefaultServerTransportSecurityValidator;
import io.modelcontextprotocol.spec.McpSchema;
import java.time.Duration;
import org.springframework.ai.mcp.server.webflux.transport.WebFluxStatelessServerTransport;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
Expand Down Expand Up @@ -53,7 +52,6 @@ class HaloMcpServer {
.capabilities(McpSchema.ServerCapabilities.builder()
.tools(false)
.build())
.requestTimeout(Duration.ofSeconds(30))
.tools(builtInTools.specifications())
.build();
}
Expand Down
65 changes: 53 additions & 12 deletions src/main/java/run/halo/mcpserver/McpAccessKeyService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
Expand Down Expand Up @@ -123,18 +124,32 @@ Mono<McpKeyAuthenticationToken> authenticate(
}
return client.fetch(McpAccessKey.class, parsed.id())
.filter(this::active)
.flatMap(accessKey -> matches(parsed.secret(), accessKey.getSpec().getKeyHash())
.filter(Boolean::booleanValue)
.filter(ignored -> McpIpAllowlist.allows(
accessKey.getSpec().getAllowedIpRanges(), remoteAddress))
.flatMap(ignored -> touch(accessKey).thenReturn(new McpKeyAuthenticationToken(
parsed.id(),
accessKey.getSpec().getDisplayName(),
accessKey.getSpec().getKeyPrefix(),
accessKey.getSpec().getOwnerName(),
accessKey.getSpec().getAllowedTools() == null
? Set.of()
: accessKey.getSpec().getAllowedTools()))));
.flatMap(accessKey -> {
var expected = AuthenticationState.from(accessKey);
return matches(parsed.secret(), expected.keyHash())
.filter(Boolean::booleanValue)
.filter(ignored -> McpIpAllowlist.allows(
expected.allowedIpRanges(), remoteAddress))
.flatMap(ignored -> touch(accessKey)
.then(revalidate(parsed.id(), expected, remoteAddress)))
.map(current -> new McpKeyAuthenticationToken(
parsed.id(),
current.getSpec().getDisplayName(),
current.getSpec().getKeyPrefix(),
current.getSpec().getOwnerName(),
current.getSpec().getAllowedTools() == null
? Set.of()
: current.getSpec().getAllowedTools()));
});
}

private Mono<McpAccessKey> revalidate(
String id, AuthenticationState expected, InetSocketAddress remoteAddress) {
return client.fetch(McpAccessKey.class, id)
.filter(this::active)
.filter(accessKey -> expected.equals(AuthenticationState.from(accessKey)))
.filter(accessKey -> McpIpAllowlist.allows(
accessKey.getSpec().getAllowedIpRanges(), remoteAddress));
}

private Mono<McpAccessKey> get(String id) {
Expand Down Expand Up @@ -233,6 +248,32 @@ private static Set<String> copyTools(Set<String> allowedTools) {

record CreatedKey(McpAccessKey accessKey, String token) {}

private record AuthenticationState(
String keyHash,
String ownerName,
boolean enabled,
Instant expiresAt,
Set<String> allowedTools,
Set<String> allowedIpRanges) {

private static AuthenticationState from(McpAccessKey accessKey) {
var spec = accessKey.getSpec();
return new AuthenticationState(
spec.getKeyHash(),
spec.getOwnerName(),
spec.isEnabled(),
spec.getExpiresAt(),
immutableSet(spec.getAllowedTools()),
immutableSet(spec.getAllowedIpRanges()));
}

private static Set<String> immutableSet(Set<String> values) {
return values == null
? Set.of()
: Collections.unmodifiableSet(new LinkedHashSet<>(values));
}
}

private record ParsedKey(String id, String secret) {}

static final class AccessKeyNotFoundException extends RuntimeException {
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/run/halo/mcpserver/McpAuthorization.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ public Mono<String> username() {
return authentication().map(McpKeyAuthenticationToken::getName);
}

public Mono<String> keyId() {
return authentication().map(McpKeyAuthenticationToken::keyId);
}

Mono<Set<String>> allowedTools() {
return authentication().map(McpKeyAuthenticationToken::allowedTools);
}
Expand Down
60 changes: 54 additions & 6 deletions src/main/java/run/halo/mcpserver/McpIpAllowlist.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,8 @@ static boolean allows(Set<String> ranges, InetSocketAddress remoteAddress) {
if (ranges == null || ranges.isEmpty()) {
return true;
}
if (remoteAddress == null) {
return false;
}
try {
var address = remoteAddress.getAddress() == null
? parseNumericAddress(remoteAddress.getHostString())
: remoteAddress.getAddress();
var address = resolve(remoteAddress);
var matchers = ranges.stream()
.map(McpIpAllowlist::compile)
.toList();
Expand All @@ -58,6 +53,46 @@ static boolean allows(Set<String> ranges, InetSocketAddress remoteAddress) {
return false;
}

static InetAddress resolve(InetSocketAddress remoteAddress) {
if (remoteAddress == null) {
throw new IllegalArgumentException("Remote address is unavailable");
}
return remoteAddress.getAddress() == null
? parseForwardedAddress(remoteAddress.getHostString())
: remoteAddress.getAddress();
}

private static InetAddress parseForwardedAddress(String address) {
try {
return parseNumericAddress(address);
} catch (IllegalArgumentException directError) {
var value = address.startsWith("[") && address.endsWith("]")
? address.substring(1, address.length() - 1)
: address;
String host;
String port;
if (value.startsWith("[")) {
var closingBracket = value.indexOf(']');
if (closingBracket < 0
|| closingBracket + 1 >= value.length()
|| value.charAt(closingBracket + 1) != ':') {
throw directError;
}
host = value.substring(1, closingBracket);
port = value.substring(closingBracket + 2);
} else {
var portSeparator = value.lastIndexOf(':');
if (portSeparator < 0) {
throw directError;
}
host = value.substring(0, portSeparator);
port = value.substring(portSeparator + 1);
}
validatePort(port);
return parseNumericAddress(host);
}
}

private static CompiledRange compile(String range) {
var matcher = InetAddressMatchers.fromIpAddress(range);
var slashIndex = range.indexOf('/');
Expand Down Expand Up @@ -94,6 +129,19 @@ private static void validateMask(String mask, int maxBits) {
}
}

private static void validatePort(String port) {
if (!port.matches("[0-9]+")) {
throw new IllegalArgumentException("Invalid port");
}
try {
if (Integer.parseInt(port) > 65_535) {
throw new IllegalArgumentException("Invalid port");
}
} catch (NumberFormatException error) {
throw new IllegalArgumentException("Invalid port", error);
}
}

private record CompiledRange(int addressLength, InetAddressMatcher matcher) {

boolean matches(InetAddress address) {
Expand Down
Loading