Friday, November 6, 2026

Artificial Intelligence Simplified - Part 3: Connect Your Java Chatbot to an MCP Tool

In Part 1, we built Pixel, a local Java chatbot; then in Part 2, we gave it the ability to check a website.

Now we will expose that checking capability as an MCP tool and connect Pixel to it.

This article includes every Java source file and the complete Gradle configuration. You can build a fresh project or use these files to update the tutorial code in your existing project. We use plain Java, Jackson, and the MCP Java SDK.

What Are We Building?

MCP means Model Context Protocol. It gives applications a standard way to discover and call tools offered by another program.

Our tool is named check_website. It accepts a hostname, invokes a Java website checker, and returns the evidence.

ComponentResponsibility
Ollama and QwenInterpret the user message and propose an action
PixelManage the conversation and choose the permitted action
MCP clientDiscover and call check_website
MCP serverReceive the call and invoke the website checker
Website checkerQuery public DNS and check the HTTPS homepage

The request follows this path: your message → Qwen's proposal → Pixel's MCP client → MCP server → website checker → Pixel's displayed result.

Pixel launches the MCP server as a child Java process. They communicate through standard input and standard output, called stdio. You do not start the server separately or configure an HTTP listening port.

Step 1: Prepare the Project

Use JDK 17 or later. You also need Docker and the Ollama setup from Part 1 for the conversation test. Tool discovery can be tested without Ollama.

All ten Java files below use this package:

JAVA · SCROLL TO READ
package com.technoroy;

Place them together in src/main/java/com/technoroy:

FilePurpose
Pixel.javaApplication entry point and connection lifecycle
Conversation.javaTerminal input, history, and action dispatch
OllamaClient.javaStructured requests to the model
ModelDecision.javaValidation and normalization of the model proposal
McpWebsiteClient.javaConnection to the MCP website tool
WebsiteMcpServer.javaRegistration and execution of the MCP tool
WebsiteChecker.javaCoordination of DNS and HTTPS checks
GoogleDnsClient.javaGoogle Public DNS integration
HttpsChecker.javaHTTPS homepage request
McpToolTest.javaIndependent discovery and invocation test

Replace the earlier tutorial versions rather than keeping duplicate copies. In an existing project, keep unrelated code and merge the Gradle additions where appropriate. The complete build file below is suitable for a fresh tutorial project.

Project Names and Packages

The run commands use Gradle task names, not paths containing a project name. You may rename the project directory or change rootProject.name without changing those commands.

The build detects each entry point's package from its source declaration. The MCP client obtains the server's full class name through WebsiteMcpServer.class.getName().

If you choose another package, refactor all ten classes together and move them to the corresponding source directory. No server class-name string or run command needs editing. Splitting classes across different packages still requires the usual Java imports.

Step 2: Configure Gradle

Create settings.gradle in the project root:

GROOVY · SCROLL TO READ
// This name may be changed without changing the commands in the article.
rootProject.name = 'pixel-mcp-demo'

Create build.gradle alongside it:

GROOVY · SCROLL TO READ
plugins {
    id 'application'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.19.2'
    implementation 'io.modelcontextprotocol.sdk:mcp:0.16.0'
    runtimeOnly 'org.slf4j:slf4j-simple:2.0.17'
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

// Find each entry point's declared package instead of hardcoding a class name.
// Keep exactly one source file for each entry point in this project.
def entryPoint = { String simpleName ->
    def matches = sourceSets.main.allJava.files.findAll {
        it.name == "${simpleName}.java"
    }
    if (matches.size() != 1) {
        throw new GradleException(
                "Expected exactly one ${simpleName}.java; found ${matches.size()}.")
    }
    def matcher = matches.first().getText('UTF-8') =~
            /(?m)^\s*package\s+([\w.]+)\s*;/
    return matcher.find() ? matcher.group(1) + '.' + simpleName : simpleName
}

application {
    mainClass.set(providers.provider { entryPoint('Pixel') })
}

tasks.register('runPixel', JavaExec) {
    group = 'application'
    description = 'Run Pixel with its MCP website tool.'
    dependsOn tasks.named('classes')
    classpath = sourceSets.main.runtimeClasspath
    mainClass.set(providers.provider { entryPoint('Pixel') })
    standardInput = System.in
}

tasks.register('listMcpTools', JavaExec) {
    group = 'application'
    description = 'Discover MCP tools without calling a model or website.'
    dependsOn tasks.named('classes')
    classpath = sourceSets.main.runtimeClasspath
    mainClass.set(providers.provider { entryPoint('McpToolTest') })
    args '--list'
}

tasks.register('checkWebsite', JavaExec) {
    group = 'application'
    description = 'Call the website MCP tool; use -Phostname=www.example.com.'
    dependsOn tasks.named('classes')
    classpath = sourceSets.main.runtimeClasspath
    mainClass.set(providers.provider { entryPoint('McpToolTest') })
    args providers.gradleProperty('hostname').orElse('www.example.com').get()
}

Use the pinned dependency versions so that the APIs match these sources. The logging provider enables SDK diagnostics and avoids the missing-provider warning.

The custom tasks provide three stable commands: listMcpTools, checkWebsite, and runPixel. You do not need to calculate a distribution directory or type a fully qualified main-class name.

Use the Gradle Wrapper included in the source download or your existing project. If creating the files manually without a wrapper, run this once using an installed Gradle:

BASH · SCROLL TO READ
gradle wrapper --gradle-version 8.14.3

Step 3: Add the Conversation Classes

These classes retain the approach from Part 2. The model proposes an action; Java decides what to execute.

ModelDecision.java

The model must provide three string fields. For a website check, any extra generated reply text is discarded: the tool supplies the evidence. Unknown actions and missing required input are rejected.

JAVA · SCROLL TO READ
package com.technoroy;

import com.fasterxml.jackson.databind.JsonNode;

/**
 * Validated proposal from the language model. A proposal is data, never executable code.
 * @param action either chat or check_website
 * @param hostname hostname for a check; empty for chat
 * @param reply conversational response; empty for a check
 */
public record ModelDecision(String action, String hostname, String reply) {
    /**
     * Enforces the field combinations supported by this application.
     * The JSON schema helps generation, but Java remains the validation boundary.
     * @throws IllegalArgumentException if fields are missing or inconsistent
     */
    public ModelDecision {
        if (action == null || hostname == null || reply == null) {
            throw new IllegalArgumentException("Missing decision fields.");
        }
        action = action.trim();
        hostname = hostname.trim();
        switch (action) {
            case "check_website" -> {
                if (hostname.isBlank() || hostname.length() > 253) {
                    throw new IllegalArgumentException(
                            "Website check requires a hostname between 1 and 253 characters.");
                }
                // Discard extra model prose: only the tool supplies check evidence.
                reply = "";
            }
            case "chat" -> {
                if (reply.isBlank()) {
                    throw new IllegalArgumentException("Chat action requires a reply.");
                }
                hostname = ""; // Chat does not invoke the website tool.
            }
            default -> throw new IllegalArgumentException(
                    "Unsupported model action: " + action);
        }
    }

    /**
     * Converts JSON into a decision only after checking its structure and field types.
     * @param node parsed model response
     * @return a validated immutable proposal
     * @throws IllegalArgumentException for an unexpected response shape
     */
    public static ModelDecision fromJson(JsonNode node) {
        if (node == null || !node.isObject() || node.size() != 3) {
            throw new IllegalArgumentException("Invalid model response.");
        }
        return new ModelDecision(text(node, "action"), text(node, "hostname"),
                text(node, "reply"));
    }

    /** Reads a string without coercing a number, boolean, or missing value into text. */
    private static String text(JsonNode node, String field) {
        JsonNode value = node.get(field);
        if (value == null || !value.isTextual()) {
            throw new IllegalArgumentException("Missing or invalid field: " + field);
        }
        return value.asText();
    }
}

OllamaClient.java

Sends the prompt, recent history, and current message to Ollama. It prints the original model decision before validation so that interpretation problems are visible.

JAVA · SCROLL TO READ
package com.technoroy;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Calls Ollama to translate conversation into a structured proposal.
 * This class cannot perform website checks; it only returns a ModelDecision.
 */
public final class OllamaClient {
    private static final String SYSTEM_PROMPT = """
            You are Pixel, a friendly IT help-desk assistant.
            Return only JSON matching the supplied schema.
            If the user cannot access a specific website, asks whether it is
            accessible, or asks you to check it:
              action = "check_website"
              hostname = hostname only, without scheme, path, or port
              reply = ""
            Use recent conversation context when the reference is clear.
            If no hostname is known, ask for it using the chat action.
            Otherwise:
              action = "chat"
              hostname = ""
              reply = a concise helpful response
            Example:
            User: I am unable to access www.gmail.com
            Response:
            {"action":"check_website","hostname":"www.gmail.com","reply":""}

            Example:
            User: Hello
            Response:
            {"action":"chat","hostname":"","reply":"Hello! How can I help?"}

            Never invent website-check results or claim to execute commands.
            Never ask for passwords or recovery codes.
            """;
    private static final String SCHEMA = """
            {
              "type":"object",
              "properties":{
                "action":{"type":"string","enum":["chat","check_website"]},
                "hostname":{"type":"string"},
                "reply":{"type":"string"}
              },
              "required":["action","hostname","reply"],
              "additionalProperties":false
            }
            """;
    private final HttpClient http;
    private final ObjectMapper json;
    private final URI endpoint;
    private final String model;

    /**
     * Receives dependencies explicitly, avoiding hidden network configuration.
     * @param http reusable HTTP client
     * @param json JSON serializer and parser
     * @param endpoint Ollama chat endpoint, owned by application configuration
     * @param model installed Ollama model name
     */
    public OllamaClient(HttpClient http, ObjectMapper json, URI endpoint, String model) {
        this.http = http;
        this.json = json;
        this.endpoint = endpoint;
        this.model = model;
    }

    /**
     * Requests and validates one model decision without mutating conversation history.
     * @param history completed user/assistant turns
     * @param input current user message
     * @return validated proposal for the application to dispatch
     * @throws IOException on transport errors or invalid JSON
     * @throws InterruptedException if the calling thread is interrupted
     * @throws IllegalArgumentException if the generated decision is invalid
     */
    public ModelDecision decide(List<Map<String, String>> history, String input)
            throws IOException, InterruptedException {
        List<Map<String, String>> messages = new ArrayList<>();
        messages.add(Map.of("role", "system", "content",
                SYSTEM_PROMPT + "\nSchema:\n" + SCHEMA));
        messages.addAll(history);
        messages.add(Map.of("role", "user", "content", input));

        // Supplying the schema as format constrains output structure, not intent.
        String body = json.writeValueAsString(Map.of(
                "model", model, "messages", messages,
                "format", json.readTree(SCHEMA), "stream", false, "think", false,
                "options", Map.of("temperature", 0, "num_ctx", 4096,
                        "num_predict", 300)));
        HttpRequest request = HttpRequest.newBuilder(endpoint)
                .timeout(Duration.ofMinutes(3))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body)).build();
        var response = http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            throw new IOException("Ollama returned HTTP " + response.statusCode());
        }
        var envelope = json.readTree(response.body());
        if (envelope == null) {
            throw new IOException("Ollama returned an empty JSON response.");
        }
        var contentNode = envelope.path("message").path("content");
        if (!contentNode.isTextual() || contentNode.asText().isBlank()) {
            throw new IOException("Ollama returned no textual decision.");
        }
        // The model's decision is JSON inside Ollama's message.content string.
        var decisionJson = json.readTree(contentNode.asText());
        System.out.println("[Model decision] " + decisionJson);
        return ModelDecision.fromJson(decisionJson);
    }
}

Conversation.java

Handles /reset and /exit, keeps four completed turns, and dispatches website checks through McpWebsiteClient.

JAVA · SCROLL TO READ
package com.technoroy;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Scanner;

/** Owns terminal interaction, bounded conversation history, and action dispatch. */
public final class Conversation {
    private final OllamaClient ollama;
    private final McpWebsiteClient checker;
    private final List<Map<String, String>> history = new ArrayList<>();

    /**
     * Creates a conversation with its model and permitted capability.
     * @param ollama structured model client
     * @param checker MCP client for the website-checking action
     */
    public Conversation(OllamaClient ollama, McpWebsiteClient checker) {
        this.ollama = ollama;
        this.checker = checker;
    }

    /**
     * Reads messages until /exit, end of input, or interruption.
     * A failed turn is reported without adding a fabricated result to history.
     * @param input terminal input; the caller owns and closes this Scanner
     */
    public void run(Scanner input) {
        while (true) {
            System.out.print("\nYou: ");
            if (!input.hasNextLine()) break;
            String text = input.nextLine().trim();
            if (text.equalsIgnoreCase("/exit")) break;
            if (text.equalsIgnoreCase("/reset")) {
                history.clear();
                System.out.println("Pixel: Conversation cleared.");
                continue;
            }
            if (text.isBlank()) continue;
            if (text.length() > 1500) {
                System.out.println("Please use 1,500 characters or fewer.");
                continue;
            }
            try {
                System.out.println("Pixel is preparing a reply...");
                ModelDecision decision = ollama.decide(history, text);
                // Only this predefined action can reach the checking capability.
                String answer = decision.action().equals("chat")
                        ? decision.reply() : checker.check(decision.hostname());
                System.out.println("\nPixel: " + answer);
                history.add(Map.of("role", "user", "content", text));
                history.add(Map.of("role", "assistant", "content", answer));
                // Retain four complete turns, including the actual observed result.
                while (history.size() > 8) history.subList(0, 2).clear();
            } catch (InterruptedException exception) {
                Thread.currentThread().interrupt(); // Preserve cancellation intent.
                break;
            } catch (IOException | IllegalArgumentException exception) {
                System.out.println("Pixel: I could not complete that request. "
                        + exception.getMessage());
            }
        }
        System.out.println("Goodbye!");
    }
}

Pixel.java

Creates the model client and opens one MCP session for the conversation. The try-with-resources block closes the MCP connection and its child process when Pixel exits.

JAVA · SCROLL TO READ
package com.technoroy;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Scanner;

/**
 * Entry point and composition root: creates the application objects and connects them.
 * No model interpretation or website-checking logic belongs in this class.
 */
public final class Pixel {
    private Pixel() { }

    /**
     * Starts the interactive CLI using the selected Ollama model.
     * @param args optional first argument: model name; defaults to qwen3:0.6b
     */
    public static void main(String[] args) {
        String model = args.length == 0 ? "qwen3:0.6b" : args[0];
        String base = System.getenv().getOrDefault(
                "OLLAMA_URL", "http://127.0.0.1:11434").replaceAll("/+$", "");

        // A configured ObjectMapper and HttpClient can be reused across requests.
        ObjectMapper json = new ObjectMapper();
        HttpClient http = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .followRedirects(HttpClient.Redirect.NEVER)
                .build();
        OllamaClient ollama = new OllamaClient(
                http, json, URI.create(base + "/api/chat"), model);

        System.out.println("Pixel | model: " + model);
        System.out.println("Describe a public website-access problem.");
        System.out.println("Commands: /reset, /exit");
        // Open one MCP session and close it automatically when the conversation ends.
        try (McpWebsiteClient checker = McpWebsiteClient.connect();
             Scanner input = new Scanner(System.in)) {
            new Conversation(ollama, checker).run(input);
        } catch (IOException exception) {
            System.out.println("Pixel: " + exception.getMessage());
        }
    }
}

Step 4: Add the MCP Connection

The client and server below expose one capability. They do not implement general-purpose planning or automatic selection from an arbitrary catalog of tools.

McpWebsiteClient.java

Starts the server, initializes the session, confirms that check_website exists, and provides the simple check(hostname) method used by Conversation.

JAVA · SCROLL TO READ
package com.technoroy;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.TextContent;

import java.io.IOException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.stream.Collectors;

/** Keeps one MCP connection open for the duration of a Pixel conversation. */
public final class McpWebsiteClient implements AutoCloseable {
    private final McpSyncClient client;

    private McpWebsiteClient(McpSyncClient client) {
        this.client = client;
    }

    /**
     * Launches the server, initializes MCP, and verifies the required tool exists.
     * @return connected client owned by the caller
     * @throws IOException if startup or tool discovery fails
     */
    public static McpWebsiteClient connect() throws IOException {
        String executable = System.getProperty("os.name").startsWith("Windows")
                ? "java.exe" : "java";
        String javaCommand = Path.of(System.getProperty("java.home"),
                "bin", executable).toString();
        var parameters = ServerParameters.builder(javaCommand)
                .args("-cp", System.getProperty("java.class.path"),
                        WebsiteMcpServer.class.getName())
                .build();
        var transport = new StdioClientTransport(parameters,
                new JacksonMcpJsonMapper(new ObjectMapper()));
        var client = McpClient.sync(transport)
                .requestTimeout(Duration.ofSeconds(90)).build();
        try {
            client.initialize();
            boolean available = client.listTools().tools().stream()
                    .anyMatch(tool -> tool.name().equals("check_website"));
            if (!available) throw new IOException("check_website is not available.");
            return new McpWebsiteClient(client);
        } catch (Exception exception) {
            client.closeGracefully(); // Also clean up after an unsuccessful connection.
            throw new IOException("Could not connect to the website MCP server: "
                    + exception.getMessage(), exception);
        }
    }

    /**
     * Invokes the server's website tool and returns its text evidence.
     * @param hostname hostname extracted by the existing model client
     * @return text returned by the tool
     * @throws IOException if invocation fails or the tool reports an error
     */
    public String check(String hostname) throws IOException {
        System.out.println("[MCP] Calling check_website for " + hostname);
        try {
            var result = client.callTool(new CallToolRequest(
                    "check_website", Map.of("hostname", hostname)));
            String text = result.content().stream()
                    .filter(TextContent.class::isInstance)
                    .map(TextContent.class::cast)
                    .map(TextContent::text)
                    .collect(Collectors.joining("\n"));
            if (Boolean.TRUE.equals(result.isError())) throw new IOException(text);
            if (text.isBlank()) throw new IOException("The tool returned no text.");
            return text;
        } catch (RuntimeException exception) {
            // Translate SDK failures into the error type Conversation already handles.
            throw new IOException("The MCP website check failed.", exception);
        }
    }

    /** Closes the MCP session and stops the child server process. */
    @Override
    public void close() {
        client.closeGracefully();
    }
}

WebsiteMcpServer.java

Registers the tool with a name, description, input schema, and handler. The handler checks its arguments before calling WebsiteChecker.

The original stdout stream is reserved for protocol messages. Ordinary progress messages are redirected to stderr in this child process, so they cannot corrupt the MCP connection.

JAVA · SCROLL TO READ
package com.technoroy;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.Tool;

import java.io.PrintStream;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;

/** Exposes the Part 2 website checker as one MCP tool. */
public class WebsiteMcpServer {

    /** Describes the single named argument accepted by our tool. */
    private static final String INPUT_SCHEMA = """
            {
              "type": "object",
              "properties": {
                "hostname": {
                  "type": "string",
                  "description": "Public hostname, such as www.example.com",
                  "minLength": 1
                }
              },
              "required": ["hostname"],
              "additionalProperties": false
            }
            """;

    /**
     * Starts the server. An MCP client launches and stops this process.
     * @param args unused
     * @throws Exception if server setup is unsuccessful or interrupted
     */
    public static void main(String[] args) throws Exception {
        // Reserve the original stdout for MCP messages. Send the existing
        // checker's progress messages to stderr instead, without editing it.
        PrintStream protocolOutput = System.out;
        System.setOut(System.err);

        ObjectMapper json = new ObjectMapper();
        var mapper = new JacksonMcpJsonMapper(json);
        HttpClient http = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .followRedirects(HttpClient.Redirect.NEVER)
                .build();
        WebsiteChecker checker = new WebsiteChecker(
                new GoogleDnsClient(http, json), new HttpsChecker(http));

        // Describe the tool, then connect it to our existing Java capability.
        Tool tool = Tool.builder()
                .name("check_website")
                .description("Check public DNS and the HTTPS homepage.")
                .inputSchema(mapper, INPUT_SCHEMA)
                .build();

        var transport = new StdioServerTransportProvider(
                mapper, System.in, protocolOutput);
        var server = McpServer.sync(transport)
                .serverInfo("pixel-website-tools", "1.0.0")
                .capabilities(ServerCapabilities.builder().tools(false).build())
                .toolCall(tool, (exchange, request) -> check(checker, request.arguments()))
                .build();

        Runtime.getRuntime().addShutdownHook(new Thread(server::closeGracefully));
        // Keep the process alive while the SDK handles incoming requests.
        new CountDownLatch(1).await();
    }

    /**
     * Validates incoming arguments and delegates to the existing checker.
     * @param checker capability from Part 2
     * @param arguments named values supplied by the MCP client
     * @return evidence on success, or an MCP tool error on failure
     */
    private static CallToolResult check(WebsiteChecker checker,
                                        Map<String, Object> arguments) {
        if (arguments == null || !arguments.keySet().equals(Set.of("hostname"))
                || !(arguments.get("hostname") instanceof String hostname)
                || hostname.isBlank()) {
            return new CallToolResult("Provide one nonblank hostname string.", true);
        }
        try {
            // The existing checker also validates the hostname's syntax.
            return new CallToolResult(checker.check(hostname), false);
        } catch (InterruptedException exception) {
            Thread.currentThread().interrupt();
            return new CallToolResult("The website check was interrupted.", true);
        } catch (Exception exception) {
            return new CallToolResult("The website check could not complete. "
                    + "No availability conclusion was reached.", true);
        }
    }
}

A completed tool call may report a DNS failure or an HTTP error. That is evidence about the target. The MCP error flag is used when the invocation itself cannot complete or the arguments are invalid.

Step 5: Add the Website-Checking Classes

These are the supporting classes from Part 2, included here in full. The Google DNS lookup and the HTTPS request have different perspectives: public DNS supplies the records, while the HTTPS client uses the server machine's normal DNS and network.

WebsiteChecker.java

Validates the hostname, queries both address families, and coordinates the HTTPS check.

JAVA · SCROLL TO READ
package com.technoroy;

import java.io.IOException;
import java.util.Locale;

/**
 * Coordinates hostname validation, public DNS lookups, and an HTTPS request.
 * This capability has no dependency on Ollama or terminal conversation history,
 * making it a natural candidate for later exposure through an MCP adapter.
 */
public final class WebsiteChecker {
    private final GoogleDnsClient dns;
    private final HttpsChecker https;

    /**
     * @param dns external DNS API client
     * @param https HTTPS homepage checker
     */
    public WebsiteChecker(GoogleDnsClient dns, HttpsChecker https) {
        this.dns = dns;
        this.https = https;
    }

    /**
     * Performs a bounded sequence of checks and formats the immediate evidence.
     * Both record types are queried because a website can support IPv4, IPv6, or both.
     * An API transport failure aborts the turn; it is not evidence of a website outage.
     * @param value hostname proposed by the model
     * @return DNS findings and, when an address is found, the HTTPS result
     * @throws IllegalArgumentException for unsupported hostname syntax
     * @throws IOException if the DNS API request or response fails
     * @throws InterruptedException if either external operation is interrupted
     */
    public String check(String value) throws IOException, InterruptedException {
        String hostname = validateHostname(value);
        System.out.println("[Action] Public DNS lookup: " + hostname);
        GoogleDnsClient.Answer ipv4 = dns.lookup(hostname, "A");
        GoogleDnsClient.Answer ipv6 = dns.lookup(hostname, "AAAA");
        String statuses = "DNS status codes: A=" + ipv4.status()
                + ", AAAA=" + ipv6.status() + ".";
        if (!ipv4.hasAddress() && !ipv6.hasAddress()) {
            return "Google Public DNS returned no usable A or AAAA address for "
                    + hostname + ". " + statuses
                    + " This does not establish domain-registration status.";
        }
        System.out.println("[Action] HTTPS request: " + hostname);
        return "Google Public DNS returned an address. " + statuses + "\n"
                + https.check(hostname);
    }

    /**
     * Accepts ASCII DNS hostnames, excluding schemes, paths, ports, and IP literals.
     * This validates syntax only: a valid name can still resolve to a private address.
     * It is not a public-destination filter or a defence against DNS rebinding.
     * @param value hostname supplied by the model
     * @return trimmed, lowercase hostname
     * @throws IllegalArgumentException if the hostname syntax is unsupported
     */
    private static String validateHostname(String value) {
        if (value == null) throw new IllegalArgumentException("Provide a hostname.");
        String hostname = value.trim().toLowerCase(Locale.ROOT);
        if (hostname.length() > 253 || !hostname.matches(
                "(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}")) {
            throw new IllegalArgumentException(
                    "Use a hostname such as www.example.com, without a scheme, path, or port.");
        }
        return hostname;
    }
}

GoogleDnsClient.java

Queries Google Public DNS for A and AAAA records. DNS response codes are separate from the HTTP status of the API request.

JAVA · SCROLL TO READ
package com.technoroy;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

/** Calls Google Public DNS's external JSON API for address-record evidence. */
public final class GoogleDnsClient {
    private final HttpClient http;
    private final ObjectMapper json;

    /**
     * @param http reusable HTTP client with redirects disabled
     * @param json JSON response parser
     */
    public GoogleDnsClient(HttpClient http, ObjectMapper json) {
        this.http = http;
        this.json = json;
    }

    /**
     * Captures both DNS status and whether a requested address record was returned.
     * @param status DNS response code, distinct from an HTTP status code
     * @param hasAddress true if a successful response contains the requested record
     */
    public record Answer(int status, boolean hasAddress) { }

    /**
     * Looks up A or AAAA records without treating an empty answer as non-registration.
     * @param hostname hostname already validated by WebsiteChecker
     * @param type A for IPv4 or AAAA for IPv6
     * @return DNS status and address-record presence
     * @throws IOException if the API cannot be reached or its response is invalid
     * @throws InterruptedException if interrupted during the request
     * @throws IllegalArgumentException if an unsupported record type is requested
     */
    public Answer lookup(String hostname, String type)
            throws IOException, InterruptedException {
        int recordType = switch (type) {
            case "A" -> 1;
            case "AAAA" -> 28;
            default -> throw new IllegalArgumentException("Use A or AAAA.");
        };
        String encoded = URLEncoder.encode(hostname, StandardCharsets.UTF_8);
        URI uri = URI.create("https://dns.google/resolve?name=" + encoded + "&type=" + type);
        HttpRequest request = HttpRequest.newBuilder(uri)
                .timeout(Duration.ofSeconds(10))
                .header("Accept", "application/json").GET().build();
        var response = http.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            throw new IOException("Google DNS API returned HTTP " + response.statusCode());
        }
        JsonNode result = json.readTree(response.body());
        if (result == null || !result.path("Status").isIntegralNumber()) {
            throw new IOException("Unexpected DNS API response.");
        }
        int status = result.path("Status").intValue();
        if (status == 0) {
            // CNAME records may also appear; count only the requested address type.
            for (JsonNode answer : result.path("Answer")) {
                if (answer.path("type").asInt() == recordType
                        && !answer.path("data").asText("").isBlank()) {
                    return new Answer(status, true);
                }
            }
        }
        return new Answer(status, false);
    }
}

HttpsChecker.java

Checks the HTTPS homepage without following redirects or consuming the complete page body.

JAVA · SCROLL TO READ
package com.technoroy;

import javax.net.ssl.SSLException;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;

/**
 * Checks the HTTPS homepage from the computer running Pixel.
 * Uses local DNS resolution; Google DNS answers are not used as connection targets.
 */
public final class HttpsChecker {
    private final HttpClient http;

    /**
     * Requires redirects to be disabled so every reported status describes this host.
     * @param http reusable client with Redirect.NEVER
     * @throws IllegalArgumentException if the client follows redirects
     */
    public HttpsChecker(HttpClient http) {
        if (http.followRedirects() != HttpClient.Redirect.NEVER) {
            throw new IllegalArgumentException("The checker must not follow redirects.");
        }
        this.http = http;
    }

    /**
     * Sends a GET request and reports the observed response or connection failure.
     * @param hostname hostname validated by WebsiteChecker
     * @return factual result; does not claim global availability
     * @throws InterruptedException if the request is interrupted
     */
    public String check(String hostname) throws InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(URI.create("https://" + hostname + "/"))
                .timeout(Duration.ofSeconds(10))
                .header("User-Agent", "PixelWebsiteChecker/1.0").GET().build();
        try {
            var response = http.send(request, HttpResponse.BodyHandlers.ofInputStream());
            // Do not parse HTML or feed website content into the language model.
            // Closing the stream avoids retaining/downloading the complete page;
            // some body bytes may already have arrived with the response headers.
            try (var body = response.body()) {
                int code = response.statusCode();
                String explanation;
                if (code >= 200 && code < 300) {
                    explanation = "The homepage responded successfully from this computer's network.";
                } else if (code >= 300 && code < 400) {
                    explanation = "The website redirected. The destination was not checked.";
                } else if (code == 401 || code == 403 || code == 429) {
                    explanation = "The website restricted the request. Browser access may differ.";
                } else if (code >= 500) {
                    explanation = "The website responded with a server error.";
                } else {
                    explanation = "The website responded, but its homepage returned an HTTP error.";
                }
                return hostname + " returned HTTP " + code + ". " + explanation;
            }
        } catch (HttpTimeoutException exception) {
            return hostname + ": the HTTPS request timed out. This does not establish a global outage.";
        } catch (SSLException exception) {
            return hostname + ": a trusted HTTPS connection could not be established.";
        } catch (IOException exception) {
            return hostname + ": public DNS returned an address, but the HTTPS request failed "
                    + "from this computer. Local DNS or network conditions may differ.";
        }
    }
}

Step 6: Add the Independent Test Client

McpToolTest.java

This utility tests MCP without involving Ollama. It starts the server, lists its tools, and optionally invokes the website check.

JAVA · SCROLL TO READ
package com.technoroy;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.json.jackson.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.TextContent;

import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;

/** A small test client: discovers the tool and optionally checks one hostname. */
public class McpToolTest {

    /**
     * Launches the server and makes an MCP request without using a model.
     * @param args --list for discovery only, or a hostname to check
     * @throws Exception if the MCP connection or request fails
     */
    public static void main(String[] args) throws Exception {
        boolean listOnly = args.length > 0 && args[0].equals("--list");
        String hostname = args.length == 0 ? "www.example.com" : args[0];
        String executable = System.getProperty("os.name").startsWith("Windows")
                ? "java.exe" : "java";
        String javaCommand = Path.of(System.getProperty("java.home"),
                "bin", executable).toString();

        // The SDK starts the server in a child JVM using this distribution's files.
        var parameters = ServerParameters.builder(javaCommand)
                .args("-cp", System.getProperty("java.class.path"), WebsiteMcpServer.class.getName())
                .build();
        var transport = new StdioClientTransport(parameters,
                new JacksonMcpJsonMapper(new ObjectMapper()));
        var client = McpClient.sync(transport)
                .requestTimeout(Duration.ofSeconds(90)).build();
        try {
            client.initialize(); // Establish the MCP session before using it.
            client.listTools().tools().forEach(tool ->
                    System.out.println("Available tool: " + tool.name()));
            if (listOnly) return;

            var result = client.callTool(new CallToolRequest(
                    "check_website", Map.of("hostname", hostname)));
            System.out.println(Boolean.TRUE.equals(result.isError())
                    ? "Tool error:" : "Tool result:");
            for (var content : result.content()) {
                if (content instanceof TextContent text) {
                    System.out.println(text.text());
                }
            }
        } finally {
            // Closing the client also stops the child server process.
            client.closeGracefully();
        }
    }
}

Step 7: Verify Tool Discovery

Run the command from the project root.

Windows PowerShell:

POWERSHELL · SCROLL TO READ
.\gradlew.bat listMcpTools --console=plain

macOS/Linux:

BASH · SCROLL TO READ
./gradlew listMcpTools --console=plain

Gradle compiles the application and resolves its dependencies. Pixel's test client then starts the MCP server and discovers its tool.

Among the diagnostic output, you should see:

TEXT · SCROLL TO READ
Available tool: check_website

This check requires neither Ollama nor an external website request. It verifies the MCP connection, discovery, and child-process lifecycle.

Step 8: Call the Tool Directly

Next, supply a public hostname.

Windows PowerShell:

POWERSHELL · SCROLL TO READ
.\gradlew.bat checkWebsite -Phostname=www.example.com --console=plain

macOS/Linux:

BASH · SCROLL TO READ
./gradlew checkWebsite -Phostname=www.example.com --console=plain

A successful result might resemble:

TEXT · SCROLL TO READ
Available tool: check_website
Tool result:
Google Public DNS returned an address. DNS status codes: A=0, AAAA=0.
www.example.com returned HTTP 200. The homepage responded successfully from this computer's network.

This output is illustrative. DNS answers and HTTP responses vary. You can try a name that should not resolve by supplying -Phostname=pixel-demo.invalid.

The command closes its MCP session and child server automatically.

Step 9: Prepare Ollama

If you already created the Part 1 container, start it:

BASH · SCROLL TO READ
docker start pixel-ollama

If this is your first setup, create the container instead:

BASH · SCROLL TO READ
docker run -d --name pixel-ollama -p 127.0.0.1:11434:11434 -v pixel-models:/root/.ollama ollama/ollama:latest

Ensure the model is downloaded:

BASH · SCROLL TO READ
docker exec -it pixel-ollama ollama pull qwen3:0.6b

The container's model volume preserves the download. Ollama serves the model; the separate MCP server runs the checking capability.

Step 10: Run Pixel

Windows PowerShell:

POWERSHELL · SCROLL TO READ
.\gradlew.bat runPixel --console=plain

macOS/Linux:

BASH · SCROLL TO READ
./gradlew runPixel --console=plain

These commands remain the same whether your project is called pixel-mcp-demo, junk-drawer, or something else.

Ask:

TEXT · SCROLL TO READ
I am unable to access www.gmail.com. Can you check it?

When the model selects the website-checking action, you should see lines resembling:

TEXT · SCROLL TO READ
[Model decision] {"action":"check_website","hostname":"www.gmail.com","reply":""}
[MCP] Calling check_website for www.gmail.com

Pixel then displays the actual tool result. Gmail may redirect or restrict automated requests, so a particular HTTP status is not guaranteed.

For an ordinary chat message, the model supplies a reply without a tool call. /reset clears history, and /exit ends the conversation and closes the MCP session. Stop Ollama afterwards if desired:

BASH · SCROLL TO READ
docker stop pixel-ollama

A small model can still misunderstand a request. Structured JSON makes the response shape usable; it does not guarantee correct interpretation. The printed decision lets you distinguish model-routing problems from tool failures.

What Does the Result Establish?

A public DNS address, an HTTP response, and a working business application are different observations. This example checks the HTTPS homepage from the server's network; it does not execute JavaScript, follow redirects, authenticate, or test a complete user journey.

Use public websites for this local exercise. Hostname syntax validation is not a complete public-destination filter. Exposing the server to other users or hosting it remotely requires destination restrictions and appropriate authentication and authorization.

What Have We Established?

Pixel now connects a natural-language request to a reusable MCP tool. Qwen proposes an action, Java validates and dispatches it, the MCP server runs the checker, and Pixel displays evidence returned by the tool.

The same pattern could expose an order lookup, an inventory check, or retrieval of an existing IT ticket. Creating a ticket adds a write operation and needs permission checks, confirmation of details, and duplicate prevention.

Now... Let your imagination unfurl its wings, for even the sky is only the beginning!

References

Artificial Intelligence Simplified - Part 3: Connect Your Java Chatbot to an MCP Tool

In Part 1 , we built Pixel, a local Java chatbot; then in Part 2 , we gave it the ability to check a website. Now we will expose that c...