Global API Key Auth + Claude Skill Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add Cloudflare Global API Key authentication support across cloudflare-mcp (1.0.1) and infrastructure-mcp (1.1.1), create a Claude skill for guided setup, eliminate hardcoded versions across all MCP repos, replace ASCII diagrams with Mermaid, and update Matt's config to use global key auth.
Architecture: The auth change starts in cloudflare-mcp's CloudflareRestClient which currently hardcodes Bearer token auth. We add a sealed CloudflareAuth interface with two implementations (token vs global key), then thread that through infrastructure-mcp's config, TUI, and tools. A Claude skill (markdown file) provides guided setup instructions installable alongside the MCP server. All repos get manifest-driven version resolution and Mermaid diagrams.
Tech Stack: Java 21, Maven, MCP SDK 1.1.1, JUnit 5
File Map¶
cloudflare-mcp (bump to 1.0.1)¶
- Create:
src/main/java/com/cloudflare/mcp/CloudflareAuth.java— sealed interface with two record implementations - Modify:
src/main/java/com/cloudflare/mcp/CloudflareRestClient.java— acceptCloudflareAuthinstead ofString apiToken - Modify:
src/test/java/com/cloudflare/mcp/CloudflareRestClientTest.java— add auth construction tests - Modify:
pom.xml— bump version to 1.0.1
infrastructure-mcp (bump to 1.1.1)¶
- Modify:
src/main/java/com/infrastructure/mcp/ServerConfig.java— addcloudflareApiKey,cloudflareEmail, flexible validation - Modify:
src/main/java/com/infrastructure/mcp/InfrastructureTools.java— build correct auth type from config - Modify:
src/main/java/com/infrastructure/mcp/SetupTui.java— add auth type selection on Cloudflare page - Modify:
src/main/java/com/infrastructure/mcp/InfrastructureMcpServer.java— bump SERVER_VERSION - Modify:
src/test/java/com/infrastructure/mcp/ServerConfigTest.java— tests for both auth modes - Modify:
src/test/java/com/infrastructure/mcp/InfrastructureToolsTest.java— update testConfig helper - Modify:
src/test/java/com/infrastructure/mcp/SetupTuiTest.java— tests for auth type selection - Modify:
pom.xml— bump version to 1.1.1, depend on cloudflare-mcp 1.0.1 - Modify:
README.md— document both auth methods, add skill installation section - Create:
skill.md— Claude skill for guided setup (installed to~/.claude/commands/)
Task 1: CloudflareAuth sealed interface (cloudflare-mcp)¶
Files:
- Create: /home/matt/mcp/cloudflare-mcp/src/main/java/com/cloudflare/mcp/CloudflareAuth.java
- Test: /home/matt/mcp/cloudflare-mcp/src/test/java/com/cloudflare/mcp/CloudflareAuthTest.java
- [ ] Step 1: Write the failing test
package com.cloudflare.mcp;
import org.junit.jupiter.api.Test;
import java.net.http.HttpRequest;
import static org.junit.jupiter.api.Assertions.*;
class CloudflareAuthTest {
@Test
void tokenAuthAppliesBearerHeader() {
var auth = CloudflareAuth.apiToken("my-token-123");
var builder = HttpRequest.newBuilder().uri(java.net.URI.create("https://example.com"));
auth.applyHeaders(builder);
var request = builder.GET().build();
assertEquals("Bearer my-token-123",
request.headers().firstValue("Authorization").orElse(null));
}
@Test
void globalKeyAuthAppliesKeyAndEmailHeaders() {
var auth = CloudflareAuth.globalApiKey("my-key-456", "[email protected]");
var builder = HttpRequest.newBuilder().uri(java.net.URI.create("https://example.com"));
auth.applyHeaders(builder);
var request = builder.GET().build();
assertEquals("my-key-456",
request.headers().firstValue("X-Auth-Key").orElse(null));
assertEquals("[email protected]",
request.headers().firstValue("X-Auth-Email").orElse(null));
}
@Test
void tokenAuthRejectsNull() {
assertThrows(IllegalArgumentException.class, () -> CloudflareAuth.apiToken(null));
}
@Test
void tokenAuthRejectsBlank() {
assertThrows(IllegalArgumentException.class, () -> CloudflareAuth.apiToken(" "));
}
@Test
void globalKeyAuthRejectsNullKey() {
assertThrows(IllegalArgumentException.class,
() -> CloudflareAuth.globalApiKey(null, "[email protected]"));
}
@Test
void globalKeyAuthRejectsNullEmail() {
assertThrows(IllegalArgumentException.class,
() -> CloudflareAuth.globalApiKey("key", null));
}
}
- [ ] Step 2: Run test to verify it fails
Run: cd /home/matt/mcp/cloudflare-mcp && mvn test -pl . -Dtest=CloudflareAuthTest -q
Expected: FAIL — CloudflareAuth class does not exist
- [ ] Step 3: Write implementation
package com.cloudflare.mcp;
import java.net.http.HttpRequest;
/**
* Authentication strategy for the Cloudflare API.
* Supports API Token (Bearer) and Global API Key (X-Auth-Key + X-Auth-Email).
*/
public sealed interface CloudflareAuth {
/** Apply authentication headers to the request builder. */
void applyHeaders(HttpRequest.Builder builder);
/** Create auth using a scoped API Token (Bearer authentication). */
static CloudflareAuth apiToken(String token) {
if (token == null || token.isBlank()) {
throw new IllegalArgumentException("API token must not be null or blank");
}
return new ApiToken(token);
}
/** Create auth using the Global API Key + account email. */
static CloudflareAuth globalApiKey(String apiKey, String email) {
if (apiKey == null || apiKey.isBlank()) {
throw new IllegalArgumentException("API key must not be null or blank");
}
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("Email must not be null or blank");
}
return new GlobalApiKey(apiKey, email);
}
record ApiToken(String token) implements CloudflareAuth {
@Override
public void applyHeaders(HttpRequest.Builder builder) {
builder.header("Authorization", "Bearer " + token);
}
}
record GlobalApiKey(String apiKey, String email) implements CloudflareAuth {
@Override
public void applyHeaders(HttpRequest.Builder builder) {
builder.header("X-Auth-Key", apiKey);
builder.header("X-Auth-Email", email);
}
}
}
- [ ] Step 4: Run test to verify it passes
Run: cd /home/matt/mcp/cloudflare-mcp && mvn test -pl . -Dtest=CloudflareAuthTest -q
Expected: PASS (6 tests)
- [ ] Step 5: Commit
cd /home/matt/mcp/cloudflare-mcp
git add src/main/java/com/cloudflare/mcp/CloudflareAuth.java src/test/java/com/cloudflare/mcp/CloudflareAuthTest.java
git commit -m "feat: add CloudflareAuth sealed interface for token and global key auth"
Task 2: Update CloudflareRestClient to use CloudflareAuth (cloudflare-mcp)¶
Files:
- Modify: /home/matt/mcp/cloudflare-mcp/src/main/java/com/cloudflare/mcp/CloudflareRestClient.java
- Modify: /home/matt/mcp/cloudflare-mcp/src/test/java/com/cloudflare/mcp/CloudflareRestClientTest.java
- [ ] Step 1: Write test for new constructor
Add to CloudflareRestClientTest.java:
@Test
void constructsWithApiTokenAuth() {
var auth = CloudflareAuth.apiToken("test-token");
var limiter = new RateLimiter(240);
var client = new CloudflareRestClient(auth, "account-id", limiter);
assertNotNull(client);
}
@Test
void constructsWithGlobalApiKeyAuth() {
var auth = CloudflareAuth.globalApiKey("test-key", "[email protected]");
var limiter = new RateLimiter(240);
var client = new CloudflareRestClient(auth, "account-id", limiter);
assertNotNull(client);
}
@Test
void legacyConstructorStillWorks() {
var limiter = new RateLimiter(240);
var client = new CloudflareRestClient("test-token", "account-id", limiter);
assertNotNull(client);
}
- [ ] Step 2: Run test to verify it fails
Run: cd /home/matt/mcp/cloudflare-mcp && mvn test -pl . -Dtest=CloudflareRestClientTest -q
Expected: FAIL — no constructor accepting CloudflareAuth
- [ ] Step 3: Update CloudflareRestClient
In CloudflareRestClient.java, replace the apiToken field and constructors:
Replace:
private final String apiToken;
private final String accountId;
private final RateLimiter rateLimiter;
private final HttpClient httpClient;
public CloudflareRestClient(String apiToken, String accountId, RateLimiter rateLimiter) {
this.apiToken = apiToken;
this.accountId = accountId;
this.rateLimiter = rateLimiter;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
With:
private final CloudflareAuth auth;
private final String accountId;
private final RateLimiter rateLimiter;
private final HttpClient httpClient;
public CloudflareRestClient(CloudflareAuth auth, String accountId, RateLimiter rateLimiter) {
this.auth = auth;
this.accountId = accountId;
this.rateLimiter = rateLimiter;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
}
/** Legacy constructor for backwards compatibility — uses API Token auth. */
public CloudflareRestClient(String apiToken, String accountId, RateLimiter rateLimiter) {
this(CloudflareAuth.apiToken(apiToken), accountId, rateLimiter);
}
In the request() method (line 143-174), replace:
With:
And after the builder is created (after .timeout(Duration.ofSeconds(30))), add:
The full request() method should look like:
private String request(String method, String path, String body) {
rateLimiter.checkAndRecord();
try {
var builder = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + path))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30));
auth.applyHeaders(builder);
HttpRequest request = switch (method) {
case "GET" -> builder.GET().build();
case "POST" -> builder.POST(HttpRequest.BodyPublishers.ofString(body)).build();
case "PATCH" -> builder.method("PATCH", HttpRequest.BodyPublishers.ofString(body)).build();
case "PUT" -> builder.PUT(HttpRequest.BodyPublishers.ofString(body)).build();
case "DELETE" -> builder.DELETE().build();
default -> throw new IllegalArgumentException("Unknown method: " + method);
};
log.debug("CF {} {}", method, path);
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) {
throw new CloudflareApiException("HTTP " + response.statusCode() + ": " +
truncate(response.body(), 500));
}
return response.body();
} catch (CloudflareApiException e) {
throw e;
} catch (Exception e) {
throw new CloudflareApiException("Request failed: " + e.getMessage(), e);
}
}
- [ ] Step 4: Run all tests
Run: cd /home/matt/mcp/cloudflare-mcp && mvn test -q
Expected: All tests PASS (existing tests use static parse methods, not HTTP calls)
- [ ] Step 5: Bump version to 1.0.1
In pom.xml, change:
- [ ] Step 6: Install to local Maven repo
Run: cd /home/matt/mcp/cloudflare-mcp && mvn install -DskipTests -q
Expected: BUILD SUCCESS
- [ ] Step 7: Commit
cd /home/matt/mcp/cloudflare-mcp
git add pom.xml src/main/java/com/cloudflare/mcp/CloudflareRestClient.java src/test/java/com/cloudflare/mcp/CloudflareRestClientTest.java
git commit -m "feat: support CloudflareAuth in CloudflareRestClient, bump to 1.0.1"
Task 3: Update ServerConfig for dual auth (infrastructure-mcp)¶
Files:
- Modify: /home/matt/mcp/infrastructure-mcp/src/main/java/com/infrastructure/mcp/ServerConfig.java
- Modify: /home/matt/mcp/infrastructure-mcp/src/test/java/com/infrastructure/mcp/ServerConfigTest.java
- [ ] Step 1: Write failing tests
Replace the full content of ServerConfigTest.java:
package com.infrastructure.mcp;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ServerConfigTest {
@Test
void parsesApiTokenAuth() {
ServerConfig config = ServerConfig.fromEnv(
"cf-token-123", null, null,
"cf-account-456",
"nc-user", "nc-key-789", "1.2.3.4",
"/custom/registry.json", "/usr/local/bin/fleet"
);
assertEquals("cf-token-123", config.cloudflareApiToken());
assertNull(config.cloudflareApiKey());
assertNull(config.cloudflareEmail());
assertEquals("cf-account-456", config.cloudflareAccountId());
assertTrue(config.isApiTokenAuth());
}
@Test
void parsesGlobalApiKeyAuth() {
ServerConfig config = ServerConfig.fromEnv(
null, "cf-global-key", "[email protected]",
"cf-account-456",
"nc-user", "nc-key-789", "1.2.3.4",
null, null
);
assertNull(config.cloudflareApiToken());
assertEquals("cf-global-key", config.cloudflareApiKey());
assertEquals("[email protected]", config.cloudflareEmail());
assertFalse(config.isApiTokenAuth());
}
@Test
void defaultsForOptionalValues() {
ServerConfig config = ServerConfig.fromEnv(
"cf-token", null, null,
"cf-account",
"nc-user", "nc-key", "10.0.0.1",
null, null
);
assertEquals("/home/matt/fleet/data/registry.json", config.fleetRegistryPath());
assertEquals("fleet", config.fleetBinary());
}
@Test
void throwsWhenNoCloudflareAuth() {
IllegalStateException ex = assertThrows(IllegalStateException.class, () ->
ServerConfig.fromEnv(null, null, null,
"cf-account", "nc-user", "nc-key", "1.2.3.4", null, null)
);
assertTrue(ex.getMessage().contains("CLOUDFLARE_API_TOKEN or CLOUDFLARE_API_KEY"));
}
@Test
void throwsWhenGlobalKeyWithoutEmail() {
IllegalStateException ex = assertThrows(IllegalStateException.class, () ->
ServerConfig.fromEnv(null, "cf-key", null,
"cf-account", "nc-user", "nc-key", "1.2.3.4", null, null)
);
assertTrue(ex.getMessage().contains("CLOUDFLARE_EMAIL"));
}
@Test
void throwsOnMissingAccountId() {
IllegalStateException ex = assertThrows(IllegalStateException.class, () ->
ServerConfig.fromEnv("cf-token", null, null,
null, "nc-user", "nc-key", "1.2.3.4", null, null)
);
assertTrue(ex.getMessage().contains("CLOUDFLARE_ACCOUNT_ID"));
}
@Test
void throwsOnMissingNamecheapApiKey() {
IllegalStateException ex = assertThrows(IllegalStateException.class, () ->
ServerConfig.fromEnv("cf-token", null, null,
"cf-account", "nc-user", null, "1.2.3.4", null, null)
);
assertTrue(ex.getMessage().contains("NAMECHEAP_API_KEY"));
}
@Test
void apiTokenTakesPrecedenceOverGlobalKey() {
ServerConfig config = ServerConfig.fromEnv(
"cf-token", "cf-key", "[email protected]",
"cf-account",
"nc-user", "nc-key", "1.2.3.4",
null, null
);
assertTrue(config.isApiTokenAuth());
}
}
- [ ] Step 2: Run test to verify it fails
Run: cd /home/matt/mcp/infrastructure-mcp && mvn test -pl . -Dtest=ServerConfigTest -q
Expected: FAIL — fromEnv has wrong signature
- [ ] Step 3: Rewrite ServerConfig
Replace the full content of ServerConfig.java:
package com.infrastructure.mcp;
/**
* Holds all server configuration, sourced from environment variables.
* Supports two Cloudflare auth modes: API Token (Bearer) or Global API Key (X-Auth-Key + X-Auth-Email).
*/
public record ServerConfig(
String cloudflareApiToken,
String cloudflareApiKey,
String cloudflareEmail,
String cloudflareAccountId,
String namecheapApiUser,
String namecheapApiKey,
String namecheapClientIp,
String fleetRegistryPath,
String fleetBinary
) {
private static final String DEFAULT_FLEET_REGISTRY_PATH = "/home/matt/fleet/data/registry.json";
private static final String DEFAULT_FLEET_BINARY = "fleet";
/** True if using scoped API Token auth, false if using Global API Key. */
public boolean isApiTokenAuth() {
return cloudflareApiToken != null && !cloudflareApiToken.isBlank();
}
/**
* Factory method for explicit values — primarily for testing.
*/
public static ServerConfig fromEnv(
String cloudflareApiToken,
String cloudflareApiKey,
String cloudflareEmail,
String cloudflareAccountId,
String namecheapApiUser,
String namecheapApiKey,
String namecheapClientIp,
String fleetRegistryPath,
String fleetBinary
) {
// Cloudflare auth: token OR (key + email) required
boolean hasToken = cloudflareApiToken != null && !cloudflareApiToken.isBlank();
boolean hasKey = cloudflareApiKey != null && !cloudflareApiKey.isBlank();
if (!hasToken && !hasKey) {
throw new IllegalStateException(
"Either CLOUDFLARE_API_TOKEN or CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL must be set");
}
if (hasKey && (cloudflareEmail == null || cloudflareEmail.isBlank())) {
throw new IllegalStateException(
"CLOUDFLARE_EMAIL is required when using CLOUDFLARE_API_KEY (Global API Key auth)");
}
requirePresent(cloudflareAccountId, "CLOUDFLARE_ACCOUNT_ID");
requirePresent(namecheapApiUser, "NAMECHEAP_API_USER");
requirePresent(namecheapApiKey, "NAMECHEAP_API_KEY");
requirePresent(namecheapClientIp, "NAMECHEAP_CLIENT_IP");
return new ServerConfig(
hasToken ? cloudflareApiToken : null,
hasKey ? cloudflareApiKey : null,
hasKey ? cloudflareEmail : null,
cloudflareAccountId,
namecheapApiUser,
namecheapApiKey,
namecheapClientIp,
fleetRegistryPath != null ? fleetRegistryPath : DEFAULT_FLEET_REGISTRY_PATH,
fleetBinary != null ? fleetBinary : DEFAULT_FLEET_BINARY
);
}
/**
* Factory method that reads configuration from {@link System#getenv()}.
*/
public static ServerConfig fromSystem() {
return fromEnv(
System.getenv("CLOUDFLARE_API_TOKEN"),
System.getenv("CLOUDFLARE_API_KEY"),
System.getenv("CLOUDFLARE_EMAIL"),
System.getenv("CLOUDFLARE_ACCOUNT_ID"),
System.getenv("NAMECHEAP_API_USER"),
System.getenv("NAMECHEAP_API_KEY"),
System.getenv("NAMECHEAP_CLIENT_IP"),
System.getenv("FLEET_REGISTRY_PATH"),
System.getenv("FLEET_BINARY")
);
}
private static void requirePresent(String value, String envVarName) {
if (value == null || value.isBlank()) {
throw new IllegalStateException(
"Required environment variable " + envVarName + " is missing or blank");
}
}
}
- [ ] Step 4: Run test to verify it passes
Run: cd /home/matt/mcp/infrastructure-mcp && mvn test -pl . -Dtest=ServerConfigTest -q
Expected: PASS (8 tests)
- [ ] Step 5: Commit
cd /home/matt/mcp/infrastructure-mcp
git add src/main/java/com/infrastructure/mcp/ServerConfig.java src/test/java/com/infrastructure/mcp/ServerConfigTest.java
git commit -m "feat: support Global API Key auth in ServerConfig"
Task 4: Update InfrastructureTools to build correct auth (infrastructure-mcp)¶
Files:
- Modify: /home/matt/mcp/infrastructure-mcp/src/main/java/com/infrastructure/mcp/InfrastructureTools.java
- Modify: /home/matt/mcp/infrastructure-mcp/src/test/java/com/infrastructure/mcp/InfrastructureToolsTest.java
- [ ] Step 1: Update testConfig helper in InfrastructureToolsTest
Replace the testConfig() method:
private static ServerConfig testConfig() {
return ServerConfig.fromEnv(
"test-cf-token", null, null,
"test-cf-account",
"test-nc-user", "test-nc-key", "127.0.0.1",
"/tmp/nonexistent-registry.json", "/usr/bin/false");
}
- [ ] Step 2: Run existing tests to verify they still pass
Run: cd /home/matt/mcp/infrastructure-mcp && mvn test -pl . -Dtest=InfrastructureToolsTest -q
Expected: PASS (tests should work with updated testConfig() once constructor is updated)
- [ ] Step 3: Update InfrastructureTools constructor
In InfrastructureTools.java, replace the InfrastructureTools(ServerConfig config) constructor:
InfrastructureTools(ServerConfig config) {
var cfLimiter = new com.cloudflare.mcp.RateLimiter(240);
com.cloudflare.mcp.CloudflareAuth cfAuth;
if (config.isApiTokenAuth()) {
cfAuth = com.cloudflare.mcp.CloudflareAuth.apiToken(config.cloudflareApiToken());
} else {
cfAuth = com.cloudflare.mcp.CloudflareAuth.globalApiKey(
config.cloudflareApiKey(), config.cloudflareEmail());
}
this.cloudflare = new CloudflareRestClient(cfAuth, config.cloudflareAccountId(), cfLimiter);
var ncLimiter = new com.namecheap.mcp.RateLimiter(20, 700);
this.namecheap = new NamecheapClient(
config.namecheapApiUser(), config.namecheapApiKey(),
config.namecheapClientIp(), ncLimiter);
this.fleet = new FleetClient(config.fleetRegistryPath(), config.fleetBinary());
}
- [ ] Step 4: Run all tests
Run: cd /home/matt/mcp/infrastructure-mcp && mvn test -pl . -Dtest=InfrastructureToolsTest -q
Expected: PASS
- [ ] Step 5: Commit
cd /home/matt/mcp/infrastructure-mcp
git add src/main/java/com/infrastructure/mcp/InfrastructureTools.java src/test/java/com/infrastructure/mcp/InfrastructureToolsTest.java
git commit -m "feat: build CloudflareAuth from ServerConfig in InfrastructureTools"
Task 5: Update SetupTui for auth type selection (infrastructure-mcp)¶
Files:
- Modify: /home/matt/mcp/infrastructure-mcp/src/main/java/com/infrastructure/mcp/SetupTui.java
- Modify: /home/matt/mcp/infrastructure-mcp/src/test/java/com/infrastructure/mcp/SetupTuiTest.java
- [ ] Step 1: Write failing tests for auth type
Add to SetupTuiTest.java inside a new @Nested class AuthType:
@Nested
class AuthType {
@Test
void defaultsToGlobalApiKey() {
var tui = createTui("");
assertEquals("global", tui.getCloudflareAuthType());
}
@Test
void validWithGlobalApiKey() {
var tui = createTui("");
tui.setCloudflareAuthType("global");
tui.setCloudflareApiKey("key");
tui.setCloudflareEmail("[email protected]");
tui.setCloudflareAccountId("id");
tui.setNamecheapApiUser("user");
tui.setNamecheapApiKey("key");
tui.setNamecheapClientIp("1.2.3.4");
assertTrue(tui.isConfigValid());
}
@Test
void validWithApiToken() {
var tui = createTui("");
tui.setCloudflareAuthType("token");
tui.setCloudflareApiToken("token");
tui.setCloudflareAccountId("id");
tui.setNamecheapApiUser("user");
tui.setNamecheapApiKey("key");
tui.setNamecheapClientIp("1.2.3.4");
assertTrue(tui.isConfigValid());
}
@Test
void invalidGlobalKeyWithoutEmail() {
var tui = createTui("");
tui.setCloudflareAuthType("global");
tui.setCloudflareApiKey("key");
tui.setCloudflareEmail("");
tui.setCloudflareAccountId("id");
tui.setNamecheapApiUser("user");
tui.setNamecheapApiKey("key");
tui.setNamecheapClientIp("1.2.3.4");
assertFalse(tui.isConfigValid());
}
}
Also update the existing Validation.validWhenAllRequired test:
@Test
void validWhenAllRequired() {
var tui = createTui("");
tui.setCloudflareAuthType("token");
tui.setCloudflareApiToken("token");
tui.setCloudflareAccountId("id");
tui.setNamecheapApiUser("user");
tui.setNamecheapApiKey("key");
tui.setNamecheapClientIp("1.2.3.4");
assertTrue(tui.isConfigValid());
}
And update Validation.invalidWhenPartial:
@Test
void invalidWhenPartial() {
var tui = createTui("");
tui.setCloudflareAuthType("token");
tui.setCloudflareApiToken("token");
tui.setCloudflareAccountId("id");
// Missing namecheap fields
assertFalse(tui.isConfigValid());
}
And update Validation.invalidWithEmptyToken:
@Test
void invalidWithEmptyToken() {
var tui = createTui("");
tui.setCloudflareAuthType("token");
tui.setCloudflareApiToken("");
tui.setCloudflareAccountId("id");
tui.setNamecheapApiUser("user");
tui.setNamecheapApiKey("key");
tui.setNamecheapClientIp("1.2.3.4");
assertFalse(tui.isConfigValid());
}
- [ ] Step 2: Run test to verify it fails
Run: cd /home/matt/mcp/infrastructure-mcp && mvn test -pl . -Dtest=SetupTuiTest -q
Expected: FAIL — getCloudflareAuthType() method does not exist
- [ ] Step 3: Update SetupTui
Add new fields after line 35 (after private String cloudflareApiToken = ""):
private String cloudflareAuthType = "global"; // "global" or "token"
private String cloudflareApiKey = "";
private String cloudflareEmail = "";
Replace showCloudflarePage():
private void showCloudflarePage() {
printHeader("Cloudflare", 2, 5);
out.println();
out.println(CYAN + " Cloudflare API Configuration" + RESET);
out.println();
out.println(" Auth type: " + YELLOW + "[1]" + RESET + " Global API Key "
+ YELLOW + "[2]" + RESET + " Scoped API Token");
out.println(" " + DIM + "Current: " + (cloudflareAuthType.equals("global") ? "Global API Key" : "Scoped API Token") + RESET);
out.print(" " + DIM + "Choose (1/2, enter to keep): " + RESET);
String choice = scanner.nextLine().trim();
if (choice.equals("1")) cloudflareAuthType = "global";
else if (choice.equals("2")) cloudflareAuthType = "token";
out.println();
if (cloudflareAuthType.equals("global")) {
out.println(" Using " + GREEN + "Global API Key" + RESET + " authentication");
out.println(" " + DIM + "Find your key at: https://dash.cloudflare.com/profile/api-tokens" + RESET);
out.println(" " + DIM + "(scroll down to 'Global API Key' and click 'View')" + RESET);
out.println();
cloudflareApiKey = promptField("Global API Key", cloudflareApiKey, true, true);
cloudflareEmail = promptField("Account Email", cloudflareEmail, true, false);
} else {
out.println(" Using " + GREEN + "Scoped API Token" + RESET + " authentication");
out.println(" " + DIM + "Create a token at: https://dash.cloudflare.com/profile/api-tokens" + RESET);
out.println(" Permissions: " + YELLOW + "Zone:Read+Edit, DNS:Edit, Zone Settings:Edit" + RESET);
out.println();
cloudflareApiToken = promptField("API Token", cloudflareApiToken, true, true);
}
cloudflareAccountId = promptField("Account ID", cloudflareAccountId, true, false);
out.println();
boolean valid;
if (cloudflareAuthType.equals("global")) {
valid = !cloudflareApiKey.isEmpty() && !cloudflareEmail.isEmpty() && !cloudflareAccountId.isEmpty();
} else {
valid = !cloudflareApiToken.isEmpty() && !cloudflareAccountId.isEmpty();
}
if (valid) {
out.println(" " + GREEN + " Cloudflare credentials set" + RESET);
} else {
out.println(" " + RED + " Required fields missing" + RESET);
}
printDivider();
}
Update showSummaryPage() — replace the two Cloudflare summary rows:
if (cloudflareAuthType.equals("global")) {
printSummaryRow("CF Auth Type", "Global API Key", true);
printSummaryRow("CF API Key", maskSecret(cloudflareApiKey), !cloudflareApiKey.isEmpty());
printSummaryRow("CF Email", cloudflareEmail, !cloudflareEmail.isEmpty());
} else {
printSummaryRow("CF Auth Type", "Scoped API Token", true);
printSummaryRow("CF API Token", maskSecret(cloudflareApiToken), !cloudflareApiToken.isEmpty());
}
printSummaryRow("CF Account ID", cloudflareAccountId, !cloudflareAccountId.isEmpty());
Update isConfigValid():
boolean isConfigValid() {
boolean cfValid;
if (cloudflareAuthType.equals("global")) {
cfValid = !cloudflareApiKey.isEmpty() && !cloudflareEmail.isEmpty();
} else {
cfValid = !cloudflareApiToken.isEmpty();
}
return cfValid
&& !cloudflareAccountId.isEmpty()
&& !namecheapApiUser.isEmpty()
&& !namecheapApiKey.isEmpty()
&& !namecheapClientIp.isEmpty();
}
Update doInstall() — replace the env map building section:
var env = new LinkedHashMap<String, String>();
if (cloudflareAuthType.equals("global")) {
env.put("CLOUDFLARE_API_KEY", cloudflareApiKey);
env.put("CLOUDFLARE_EMAIL", cloudflareEmail);
} else {
env.put("CLOUDFLARE_API_TOKEN", cloudflareApiToken);
}
env.put("CLOUDFLARE_ACCOUNT_ID", cloudflareAccountId);
env.put("NAMECHEAP_API_USER", namecheapApiUser);
env.put("NAMECHEAP_API_KEY", namecheapApiKey);
env.put("NAMECHEAP_CLIENT_IP", namecheapClientIp);
Add getters/setters at the end:
String getCloudflareAuthType() { return cloudflareAuthType; }
String getCloudflareApiKey() { return cloudflareApiKey; }
String getCloudflareEmail() { return cloudflareEmail; }
void setCloudflareAuthType(String v) { cloudflareAuthType = v; }
void setCloudflareApiKey(String v) { cloudflareApiKey = v; }
void setCloudflareEmail(String v) { cloudflareEmail = v; }
- [ ] Step 4: Run tests
Run: cd /home/matt/mcp/infrastructure-mcp && mvn test -pl . -Dtest=SetupTuiTest -q
Expected: PASS
- [ ] Step 5: Commit
cd /home/matt/mcp/infrastructure-mcp
git add src/main/java/com/infrastructure/mcp/SetupTui.java src/test/java/com/infrastructure/mcp/SetupTuiTest.java
git commit -m "feat: add auth type selection to SetupTui (global key vs token)"
Task 6: Version bumps and full test run (infrastructure-mcp)¶
Files:
- Modify: /home/matt/mcp/infrastructure-mcp/pom.xml
- Modify: /home/matt/mcp/infrastructure-mcp/src/main/java/com/infrastructure/mcp/InfrastructureMcpServer.java
- [ ] Step 1: Bump pom.xml version to 1.1.1 and cloudflare-mcp dependency to 1.0.1
In pom.xml, change:
And change the cloudflare-mcp dependency:
To:- [ ] Step 2: Bump SERVER_VERSION
In InfrastructureMcpServer.java, change:
- [ ] Step 3: Run full test suite
Run: cd /home/matt/mcp/infrastructure-mcp && mvn clean test -q
Expected: All tests PASS
- [ ] Step 4: Build shaded JAR
Run: cd /home/matt/mcp/infrastructure-mcp && mvn clean package -q
Expected: BUILD SUCCESS, produces target/infrastructure-mcp-1.1.1.jar
- [ ] Step 5: Commit
cd /home/matt/mcp/infrastructure-mcp
git add pom.xml src/main/java/com/infrastructure/mcp/InfrastructureMcpServer.java
git commit -m "chore: bump to v1.1.1, depend on cloudflare-mcp 1.0.1"
Task 7: Create Claude skill for setup (infrastructure-mcp)¶
Files:
- Create: /home/matt/mcp/infrastructure-mcp/skill.md
- [ ] Step 1: Create skill.md
---
name: infrastructure-setup
description: Guided setup for the Infrastructure MCP server — configures Cloudflare, Namecheap, and Fleet credentials
---
# Infrastructure MCP Server — Setup Guide
You are helping the user configure the Infrastructure MCP server. Walk them through each step below. Ask for confirmation before modifying any files.
## Prerequisites
- Java 21 installed (`java -version`)
- The JAR built at the expected path (usually `target/infrastructure-mcp-1.1.1.jar`)
- Cloudflare account
- Namecheap API access enabled
## Step 1: Cloudflare Authentication
Ask the user which auth method they prefer:
**Option A: Global API Key** (simpler, full account access)
- Find it at: https://dash.cloudflare.com/profile/api-tokens → scroll to "Global API Key" → View
- Needs: the key + the email address used to sign in to Cloudflare
- Env vars: `CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`
**Option B: Scoped API Token** (recommended for shared/CI environments)
- Create at: https://dash.cloudflare.com/profile/api-tokens → Create Token
- Permissions needed: Zone:Read+Edit, DNS:Read+Edit, Zone Settings:Read+Edit
- Zone Resources: All zones (or specific zones)
- Env var: `CLOUDFLARE_API_TOKEN`
Both options also need: `CLOUDFLARE_ACCOUNT_ID`
- Find it at: https://dash.cloudflare.com → pick any domain → right sidebar under "API" section
## Step 2: Namecheap API
- Enable API at: https://ap.www.namecheap.com/settings/tools/apiaccess
- Whitelist the server's public IP address in the Namecheap dashboard
- Env vars needed: `NAMECHEAP_API_USER`, `NAMECHEAP_API_KEY`, `NAMECHEAP_CLIENT_IP`
## Step 3: Fleet (Optional)
Only needed if you use Fleet for app deployment.
- `FLEET_REGISTRY_PATH` — path to `registry.json` (default: `/home/matt/fleet/data/registry.json`)
- `FLEET_BINARY` — path to fleet CLI (default: `fleet`)
## Step 4: Register with Claude Code
Once you have the credentials, write the config to `~/.claude.json` under `mcpServers`:
For Global API Key auth:
```json
{
"infrastructure-mcp": {
"type": "stdio",
"command": "java",
"args": ["-jar", "/path/to/infrastructure-mcp-1.1.1.jar"],
"env": {
"CLOUDFLARE_API_KEY": "<your-global-api-key>",
"CLOUDFLARE_EMAIL": "<your-cloudflare-email>",
"CLOUDFLARE_ACCOUNT_ID": "<your-account-id>",
"NAMECHEAP_API_USER": "<your-username>",
"NAMECHEAP_API_KEY": "<your-api-key>",
"NAMECHEAP_CLIENT_IP": "<your-whitelisted-ip>"
}
}
}
For API Token auth:
{
"infrastructure-mcp": {
"type": "stdio",
"command": "java",
"args": ["-jar", "/path/to/infrastructure-mcp-1.1.1.jar"],
"env": {
"CLOUDFLARE_API_TOKEN": "<your-api-token>",
"CLOUDFLARE_ACCOUNT_ID": "<your-account-id>",
"NAMECHEAP_API_USER": "<your-username>",
"NAMECHEAP_API_KEY": "<your-api-key>",
"NAMECHEAP_CLIENT_IP": "<your-whitelisted-ip>"
}
}
}
Step 5: Verify¶
After restarting Claude Code, test with: - "List my Cloudflare zones" → should return zone data - "List my Namecheap domains" → should return domain list - "List Fleet apps" → should return app data (or empty if no Fleet)
If Cloudflare returns a 403/auth error, double-check the API key/email and account ID.
- [ ] **Step 2: Commit**
```bash
cd /home/matt/mcp/infrastructure-mcp
git add skill.md
git commit -m "feat: add Claude skill for guided setup"
Task 8: Update README (infrastructure-mcp)¶
Files:
- Modify: /home/matt/mcp/infrastructure-mcp/README.md
- [ ] Step 1: Update README
Make these changes to README.md:
-
In the Prerequisites section, replace the Cloudflare line:
With: -
Replace the "2b. Manual configuration" section env vars:
With:export CLOUDFLARE_API_TOKEN='your-cloudflare-api-token' export CLOUDFLARE_ACCOUNT_ID='your-cloudflare-account-id'# Option A: Global API Key (simpler) export CLOUDFLARE_API_KEY='your-global-api-key' export CLOUDFLARE_EMAIL='your-cloudflare-email' export CLOUDFLARE_ACCOUNT_ID='your-cloudflare-account-id' # Option B: Scoped API Token # export CLOUDFLARE_API_TOKEN='your-scoped-api-token' # export CLOUDFLARE_ACCOUNT_ID='your-cloudflare-account-id' -
Replace the env vars in the
With:~/.claude/settings.jsonexample: -
Replace the Configuration env vars table:
| Variable | Description | Required | Default |
|---|---|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare scoped API token (Bearer auth) | * | — |
CLOUDFLARE_API_KEY |
Cloudflare Global API Key | * | — |
CLOUDFLARE_EMAIL |
Cloudflare account email (required with API_KEY) |
* | — |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare account ID | Yes | — |
NAMECHEAP_API_USER |
Namecheap API username | Yes | — |
NAMECHEAP_API_KEY |
Namecheap API key | Yes | — |
NAMECHEAP_CLIENT_IP |
Whitelisted IP for Namecheap API | Yes | — |
FLEET_REGISTRY_PATH |
Path to Fleet's registry.json | No | /home/matt/fleet/data/registry.json |
FLEET_BINARY |
Path to fleet CLI binary | No | fleet |
* Provide either CLOUDFLARE_API_TOKEN or CLOUDFLARE_API_KEY + CLOUDFLARE_EMAIL. If both are set, the API token takes precedence.
-
Update the JAR filename references from
infrastructure-mcp-1.0.0.jartoinfrastructure-mcp-1.1.1.jar. -
Add a "Claude Skill" section after "Quick start" section 4:
### 5. Claude Skill (optional)
Install the included skill for guided setup help:
```bash
cp skill.md ~/.claude/commands/infrastructure-setup.md
Then use /infrastructure-setup in Claude Code for step-by-step configuration guidance.
7. Update the test count if it changed (check after full test run).
- [ ] **Step 2: Commit**
```bash
cd /home/matt/mcp/infrastructure-mcp
git add README.md
git commit -m "docs: update README for dual auth, skill installation, v1.1.1"
Task 9: Update Matt's config and verify (infrastructure-mcp)¶
Files:
- Modify: /root/.claude.json (the mcpServers.infrastructure-mcp entry)
- [ ] Step 1: Read current config to get existing values
Read /root/.claude.json and note the current NAMECHEAP_* values and CLOUDFLARE_ACCOUNT_ID.
- [ ] Step 2: Update config to use Global API Key auth
In the mcpServers.infrastructure-mcp.env section:
- Remove CLOUDFLARE_API_TOKEN
- Add CLOUDFLARE_API_KEY with the value from the current CLOUDFLARE_API_TOKEN (Matt said he gave the global API key — it was just in the wrong field)
- Add CLOUDFLARE_EMAIL with value [email protected]
- Keep all other env vars unchanged
Update args to point to new JAR: ["-jar", "/home/matt/mcp/infrastructure-mcp/target/infrastructure-mcp-1.1.1.jar"]
- [ ] Step 3: Install skill
- [ ] Step 4: Verify build works
Run: cd /home/matt/mcp/infrastructure-mcp && mvn clean package -q && echo "JAR size: $(du -h target/infrastructure-mcp-1.1.1.jar | cut -f1)"
Expected: BUILD SUCCESS with JAR file
- [ ] Step 5: Note for user
Tell Matt to restart Claude Code for the new config to take effect, then test with cloudflare_list_zones.
Task 10: Runtime version from JAR manifest (all repos)¶
Files:
- Modify: /home/matt/mcp/infrastructure-mcp/pom.xml — add manifest entries to shade plugin
- Modify: /home/matt/mcp/infrastructure-mcp/src/main/java/com/infrastructure/mcp/InfrastructureMcpServer.java — read version from manifest
- Modify: /home/matt/mcp/infrastructure-mcp/src/main/java/com/infrastructure/mcp/SetupTui.java — remove hardcoded JAR name fallback
- Modify: /home/matt/mcp/cloudflare-mcp/pom.xml — add manifest entries
- Modify: /home/matt/mcp/cloudflare-mcp/src/main/java/com/cloudflare/mcp/CloudflareMcpServer.java — read version from manifest
- Modify: /home/matt/mcp/cloudflare-mcp/src/main/java/com/cloudflare/mcp/Install.java — remove hardcoded JAR name
- Modify: /home/matt/mcp/google-workspace/pom.xml — add manifest entries
- Modify: /home/matt/mcp/google-workspace/src/main/java/com/google/workspace/mcp/GoogleWorkspaceMcpServer.java — read version from manifest
- Modify: /home/matt/mcp/reddit/src/main/java/com/reddit/mcp/RedditMcpServer.java — read version from manifest
- [ ] Step 1: Add manifest entries to infrastructure-mcp pom.xml
Inside the ManifestResourceTransformer in the shade plugin config, add:
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.infrastructure.mcp.InfrastructureMcpServer</mainClass>
<manifestEntries>
<Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>${project.artifactId}</Implementation-Title>
</manifestEntries>
</transformer>
- [ ] Step 2: Replace hardcoded SERVER_VERSION in InfrastructureMcpServer.java
Replace:
With: private static final String SERVER_VERSION = resolveVersion();
private static String resolveVersion() {
String v = InfrastructureMcpServer.class.getPackage().getImplementationVersion();
return v != null ? v : "dev";
}
- [ ] Step 3: Fix hardcoded JAR name in SetupTui.java
Replace:
With: String version = InfrastructureMcpServer.class.getPackage().getImplementationVersion();
jarPath = "infrastructure-mcp-" + (version != null ? version : "1.1.1") + ".jar";
Add import at top of SetupTui.java if not present (it's in same package, no import needed).
- [ ] Step 4: Apply same pattern to cloudflare-mcp
Add manifest entries to cloudflare-mcp pom.xml shade plugin:
<manifestEntries>
<Implementation-Version>${project.version}</Implementation-Version>
<Implementation-Title>${project.artifactId}</Implementation-Title>
</manifestEntries>
In CloudflareMcpServer.java, replace:
private static final String SERVER_VERSION = resolveVersion();
private static String resolveVersion() {
String v = CloudflareMcpServer.class.getPackage().getImplementationVersion();
return v != null ? v : "dev";
}
In Install.java, replace hardcoded JAR references:
String v = Install.class.getPackage().getImplementationVersion();
String jar = "cloudflare-mcp-" + (v != null ? v : "1.0.1") + ".jar";
System.out.println("Then re-run: java -jar " + jar + " --install");
And similarly for the other Install.java reference on line 107.
- [ ] Step 5: Apply same pattern to google-workspace-mcp
Add manifest entries to google-workspace pom.xml shade plugin.
In GoogleWorkspaceMcpServer.java, replace:
static final String SERVER_VERSION = resolveVersion();
private static String resolveVersion() {
String v = GoogleWorkspaceMcpServer.class.getPackage().getImplementationVersion();
return v != null ? v : "dev";
}
Note: the test assertEquals("1.0.0", GoogleWorkspaceMcpServer.SERVER_VERSION) in GoogleWorkspaceMcpServerTest.java needs updating:
// Version comes from manifest at runtime, "dev" when running tests without packaged JAR
assertEquals("dev", GoogleWorkspaceMcpServer.SERVER_VERSION);
- [ ] Step 6: Apply same pattern to reddit-mcp
In RedditMcpServer.java, replace the inline version:
And add the helper method:
private static String resolveVersion() {
String v = RedditMcpServer.class.getPackage().getImplementationVersion();
return v != null ? v : "dev";
}
Add manifest entries to reddit pom.xml shade plugin if not present.
- [ ] Step 7: Build and test all repos
Run:
cd /home/matt/mcp/cloudflare-mcp && mvn clean install -q
cd /home/matt/mcp/infrastructure-mcp && mvn clean test -q
cd /home/matt/mcp/google-workspace && mvn clean test -q
cd /home/matt/mcp/reddit && mvn clean test -q
Expected: All PASS
- [ ] Step 8: Commit each repo
cd /home/matt/mcp/cloudflare-mcp
git add pom.xml src/main/java/com/cloudflare/mcp/CloudflareMcpServer.java src/main/java/com/cloudflare/mcp/Install.java
git commit -m "fix: read version from JAR manifest instead of hardcoding"
cd /home/matt/mcp/infrastructure-mcp
git add pom.xml src/main/java/com/infrastructure/mcp/InfrastructureMcpServer.java src/main/java/com/infrastructure/mcp/SetupTui.java
git commit -m "fix: read version from JAR manifest instead of hardcoding"
cd /home/matt/mcp/google-workspace
git add pom.xml src/main/java/com/google/workspace/mcp/GoogleWorkspaceMcpServer.java src/test/java/com/google/workspace/mcp/GoogleWorkspaceMcpServerTest.java
git commit -m "fix: read version from JAR manifest instead of hardcoding"
cd /home/matt/mcp/reddit
git add pom.xml src/main/java/com/reddit/mcp/RedditMcpServer.java
git commit -m "fix: read version from JAR manifest instead of hardcoding"
Task 11: Replace ASCII diagrams with Mermaid in infrastructure-mcp README¶
Files:
- Modify: /home/matt/mcp/infrastructure-mcp/README.md
- [ ] Step 1: Replace ASCII diagram with Mermaid
Replace the ASCII architecture diagram block:
```
┌─────────────────────────────────────────────────────────────────┐
│ Claude Code / LLM Client │
│ (MCP Client) │
└──────────────────────────────┬──────────────────────────────────┘
│ stdio
▼
┌─────────────────────────────────────────────────────────────────┐
│ Infrastructure MCP Server │
│ 12 MCP tools │
├──────────────────┬──────────────────┬───────────────────────────┤
│ Fleet Client │ Namecheap Client │ Cloudflare REST Client │
│ (registry.json │ (XML API) │ (API v4 JSON) │
│ + CLI shell) │ │ │
└────────┬─────────┴────────┬─────────┴─────────────┬─────────────┘
▼ ▼ ▼
Fleet Registry Namecheap API Cloudflare API v4
+ fleet CLI api.namecheap.com api.cloudflare.com
```
With:
```mermaid
graph TD
Client[Claude Code / LLM Client<br><i>MCP Client</i>]
Client -->|stdio| Server
subgraph Server[Infrastructure MCP Server]
Fleet[Fleet Client<br><small>registry.json + CLI</small>]
Namecheap[Namecheap Client<br><small>XML API</small>]
Cloudflare[Cloudflare REST Client<br><small>API v4 JSON</small>]
end
Fleet --> FleetAPI[Fleet Registry + CLI]
Namecheap --> NCAPI[api.namecheap.com]
Cloudflare --> CFAPI[api.cloudflare.com]
```
- [ ] Step 2: Commit