#
tokens: 7303/50000 13/13 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .DS_Store
├── .gitignore
├── docs
│   ├── javaConf-mcp-server-architecture.drawio
│   ├── javaconf-mcp-server-conversation.png
│   └── javaConf-mcp-server.png
├── pom.xml
├── README.md
└── src
    └── main
        ├── java
        │   └── nano
        │       └── dev
        │           └── javaconf
        │               ├── config
        │               │   └── McpToolConfiguration.java
        │               ├── JavaConfMcpServerApplication.java
        │               ├── model
        │               │   └── ConferenceInfo.java
        │               └── service
        │                   ├── ConferenceToolService.java
        │                   ├── GitHubService.java
        │                   ├── MarkdownParsingHelper.java
        │                   ├── MarkdownParsingService.java
        │                   └── util
        │                       └── MarkdownParsingHelper.java
        └── resources
            ├── application.properties
            └── logback-spring.xml
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Compiled class file
*.class

# Log file
*.log

# BlueJ files
*.ctxt

# Mobile Tools for Java (J2ME)
.mtj.tmp/

# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar

# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*

# MAVEN #
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
# Avoid ignoring Maven wrapper jar file (.jar files are ignored above)
!/.mvn/wrapper/maven-wrapper.jar

# IDEA #
.idea/
*.iml
*.ipr
*.iws
out/

# Eclipse #
.settings/
.classpath
.project

# NetBeans #
nbproject/private/
build/
nbbuild/
dist/
nbdist/
.nb-gradle/

# VS Code #
.vscode/

# Mac #
.DS_Store

# Other #
*.swp
*~ 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# 🔌 Java Conferences MCP Server 🔌

A Model Call Protocol (MCP) server that provides AI assistants with access to data about Java conferences from around the world. This server exposes conference information from a public GitHub repository including names, dates, locations, and Call for Papers (CFP) details through a standardized MCP interface.

## Data Source
The server fetches data by parsing the `README.md` file from the [🔗 javaconferences/javaconferences.github.io](https://github.com/javaconferences/javaconferences.github.io) repository.
The specific URL is configured in `application.properties` and defaults to:
`https://raw.githubusercontent.com/javaconferences/javaconferences.github.io/main/README.md`

## Provided Tool
*   **Name:** `getJavaConferences`
*   **Description:** Get information about Java conferences for a specific year (if specified and found in the source) or the current year by default. Parses data for all years found under H3 headings in the source markdown file.
*   **Input Parameter:**
    *   `year` (String, Optional): The 4-digit year to retrieve conferences for. If omitted or invalid, defaults to the current year.
*   **Output:** A list of JSON objects, each representing a conference with the following fields:
    *   `conferenceName` (String)
    *   `date` (String)
    *   `location` (String)
    *   `isHybrid` (Boolean)
    *   `cfpLink` (String) - URL for the Call for Papers, if available
    *   `cfpDate` (String) - Closing date for CFP, if available
    *   `link` (String) - Main conference link
    *   `country` (String)


## MCP Server Architecture

![MCP Server Architecture](./docs/javaConf-mcp-server.png)

## Configuration

## Connecting an MCP Client (e.g., Claude Desktop)

To connect an MCP client like Claude Desktop to this server:

1. Configure your MCP client to connect to the server. For Claude Desktop, you might update your `claude_desktop_config.json` file like this:

    ```json
    {
    "mcpServers": { 
      "javaConf-mcp-server": {
        "command": "java",
        // "command": "PATH_TO_USER/.sdkman/candidates/java/current/bin/java", /* in my case i'm using the java version installed by sdkman */
        "args": [
            "-jar", 
            "PATH_TO_PROJECT/javaConf-mcp-server/target/javaconf-mcp-server-0.0.1-SNAPSHOT.jar"
        ]
      }
    }
    }
    ```

2. Start the MCP client and ensure that it is connected to the server.
3. Use the tool by asking questions like:
    - "What are the upcoming Java conferences?"
    - "What are hybrid conferences?"
    - "Give the CPF link of Jfokus conference to submit a talk"

### Example of a conversation with the MCP server

![Example of a conversation with the MCP server](./docs/javaconf-mcp-server-conversation.png)

## Tech Stack

- **[`🍃️ Spring Boot`](https://spring.io/projects/spring-boot)**
- **[`🤖️ Spring AI`](https://spring.io/projects/spring-ai)**
- **[`🔌 Spring AI MCP`](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html)**
- **[`📦️ Maven`](https://maven.apache.org/)**
- **[`🧾 CommonMark`](https://commonmark.org/)**

## Support

- ⭐️️ Star this repository if you find it useful.
- 🐛️ If you find a bug, raise an issue or fix it and send a pull request.
- 📢️ If you have any feature requests, raise an issue or send a pull request.
- 🤲 If you have a moment, don't forget to make a duaa for me and my parents.

```

--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/JavaConfMcpServerApplication.java:
--------------------------------------------------------------------------------

```java
package nano.dev.javaconf;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JavaConfMcpServerApplication {

    public static void main(String[] args) {
        SpringApplication.run(JavaConfMcpServerApplication.class, args);
    }
}

```

--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/model/ConferenceInfo.java:
--------------------------------------------------------------------------------

```java
package nano.dev.javaconf.model;

import lombok.Builder;
import lombok.Data;
import lombok.extern.jackson.Jacksonized;

@Data
@Builder
@Jacksonized
public class ConferenceInfo {
    private int year;
    private String conferenceName;
    private String location;
    private Boolean isHybrid;
    private String cfpStatus;
    private String cfpLink;
    private String link;
    private String country;
}

```

--------------------------------------------------------------------------------
/src/main/resources/logback-spring.xml:
--------------------------------------------------------------------------------

```
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

    <appender name="CONSOLE_STDERR" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>${CONSOLE_LOG_CHARSET}</charset>
        </encoder>
        <target>System.err</target>
    </appender>

    <!-- Configure the Root logger -->
    <root level="INFO">
        <appender-ref ref="CONSOLE_STDERR" />
    </root>
</configuration> 
```

--------------------------------------------------------------------------------
/src/main/resources/application.properties:
--------------------------------------------------------------------------------

```
spring.application.name=javaconf-mcp-server

# MCP Server Identification
spring.ai.mcp.server.name=javaConf-mcp-server
spring.ai.mcp.server.version=0.0.1-SNAPSHOT

# Disable the Spring Boot banner when running
spring.main.banner-mode=off

# Tell Spring Boot not to start a web server
spring.main.web-application-type=none

# Logging configuration
logging.level.nano.dev.javaconf=DEBUG
logging.level.org.springframework.ai=INFO
logging.level.root=INFO
logging.level.org.springframework=INFO

# GitHub URL for the markdown file
github.markdown.url=https://raw.githubusercontent.com/javaconferences/javaconferences.github.io/main/README.md

```

--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/config/McpToolConfiguration.java:
--------------------------------------------------------------------------------

```java
package nano.dev.javaconf.config;

import lombok.extern.slf4j.Slf4j;
import nano.dev.javaconf.service.ConferenceToolService;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbacks;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;

@Configuration
@Slf4j
public class McpToolConfiguration {

    /**
     * Defines the ConferenceToolService as a ToolCallback bean for the MCP server.
     * Spring AI MCP Server automatically discovers beans of type ToolCallback.
     */
    @Bean
    public List<ToolCallback> javaConferenceTool(ConferenceToolService conferenceToolService) {
        log.debug("Registering ConferenceToolService as ToolCallback bean via McpToolConfiguration.");
        return List.of(ToolCallbacks.from(conferenceToolService));
    }
}

```

--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/GitHubService.java:
--------------------------------------------------------------------------------

```java
package nano.dev.javaconf.service;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

@Service
@Slf4j
public class GitHubService {

    private final RestTemplate restTemplate;
    private final String markdownUrl;

    public GitHubService(RestTemplateBuilder builder,
                         @Value("${github.markdown.url}") String markdownUrl
    ) {
        this.restTemplate = builder.build();
        this.markdownUrl = markdownUrl;
        log.info("GitHubService initialized to fetch from URL: {}", this.markdownUrl);
    }

    public String fetchMarkdownContent() {
        log.info("Fetching Java Conference Markdown from: {} using RestTemplate", markdownUrl);
        try {
            log.debug("Attempting to fetch Markdown content from {}", markdownUrl);
            ResponseEntity<String> response = restTemplate.getForEntity(markdownUrl, String.class);

            if (response.getStatusCode() == HttpStatus.OK) {
                String content = response.getBody();
                log.info("Successfully fetched Markdown content ({} bytes)", content != null ? content.length() : 0);
                return content;
            } else {
                log.error("Failed to fetch Markdown content. Status code: {}", response.getStatusCode());
                return null;
            }
        } catch (RestClientException e) {
            log.error("Error fetching Markdown content from {}: {}", markdownUrl, e.getMessage());
            return null;
        }
    }
}

```

--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/ConferenceToolService.java:
--------------------------------------------------------------------------------

```java
package nano.dev.javaconf.service;

import lombok.extern.slf4j.Slf4j;
import nano.dev.javaconf.model.ConferenceInfo;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.time.Year;
import java.util.Collections;
import java.util.List;

@Service
@Slf4j
public class ConferenceToolService {

    private final GitHubService gitHubService;
    private final MarkdownParsingService markdownParsingService;

    public ConferenceToolService(GitHubService gitHubService, MarkdownParsingService markdownParsingService) {
        this.gitHubService = gitHubService;
        this.markdownParsingService = markdownParsingService;
    }


    public record ToolRequest(String year) { }

    @Tool(name = "getJavaConferences",
          description = "Get information about Java conferences for a specific year (if specified and found in the source) or the current year by default. Parses data for all years found under H3 headings.")
    public List<ConferenceInfo> getJavaConferences(ToolRequest request) {
        final int finalTargetYear = determineTargetYear(request);

        try {
            String markdownContent = gitHubService.fetchMarkdownContent();

            if (!StringUtils.hasText(markdownContent)) {
                log.warn("Markdown content was null or empty after fetch, returning empty list.");
                return Collections.emptyList();
            }

            List<ConferenceInfo> allConferences = markdownParsingService.parse(markdownContent);
            log.debug("Parser returned {} conferences in total (before filtering).", allConferences.size());

            List<ConferenceInfo> filteredConferences = allConferences.stream()
                    .filter(conf -> conf.getYear() == finalTargetYear)
                    .toList();

            log.info("Returning {} conferences for year {}", filteredConferences.size(), finalTargetYear);
            return filteredConferences;

        } catch (Exception e) {
            log.error("Error processing conference info request for year {}", finalTargetYear, e);
            return Collections.emptyList();
        }
    }

    private int determineTargetYear(ToolRequest request) {
        int currentYear = Year.now().getValue();
        int targetYear;
        String requestedYear = request.year();

        if (StringUtils.hasText(requestedYear)) {
            try {
                targetYear = Integer.parseInt(requestedYear);
                log.info("Tool called: getJavaConferences attempting requested year: {}", targetYear);
            } catch (NumberFormatException e) {
                log.warn("Invalid year format '{}' requested, falling back to current year {}.", requestedYear, currentYear);
                targetYear = currentYear;
                log.info("Tool called: getJavaConferences falling back to current year: {}", targetYear);
            }
        } else {
            targetYear = currentYear;
            log.info("Tool called: getJavaConferences using current year: {} (no year specified)", targetYear);
        }
        return targetYear;
    }

}

```

--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/util/MarkdownParsingHelper.java:
--------------------------------------------------------------------------------

```java
package nano.dev.javaconf.service.util;

import org.commonmark.node.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

/**
 * Utility class containing static helper methods for parsing specific elements
 * from commonmark-java Nodes, primarily for the Java Conference MCP server.
 */
public final class MarkdownParsingHelper {

    private MarkdownParsingHelper() {}

    public static <T extends Node> Stream<T> streamChildren(Node parent, Class<T> type) {
        List<T> children = new ArrayList<>();
        Node child = parent.getFirstChild();
        while (child != null) {
            if (type.isInstance(child)) {
                 children.add(type.cast(child));
             }
            child = child.getNext();
        }
        return children.stream();
    }


    /**
     * Extracts the destination URL of the first Link node found within the children of parentNode.
     * Uses a dedicated visitor for efficiency.
     * @param parentNode The node potentially containing a Link node.
     * @return The URL string if a link is found, otherwise null.
     */
    public static String extractFirstLinkUrlFromNode(Node parentNode) {
        if (parentNode == null) return null;
        UrlExtractorVisitor visitor = new UrlExtractorVisitor();
        parentNode.accept(visitor);
        return visitor.getFirstUrl();
    }

    /**
     * Helper visitor (inner class) to find the first Link node and get its destination URL.
     */
    private static class UrlExtractorVisitor extends AbstractVisitor {
        private String firstUrl = null;

        public String getFirstUrl() {
            return this.firstUrl;
        }

        @Override
        public void visit(Link link) {
            if (this.firstUrl == null) {
                this.firstUrl = link.getDestination();
            }
        }
    }

    /**
     * Extracts all visible text content from a node and its children.
     * Handles common inline formatting nodes.
     * @param node The node to extract text from.
     * @return The extracted text as a single string.
     */
    public static String extractText(Node node) {
        if (node == null) return "";
        StringBuilder sb = new StringBuilder();
        TextExtractorVisitor textVisitor = new TextExtractorVisitor(sb);
        node.accept(textVisitor);
        return sb.toString();
    }

    /**
     * Helper visitor (inner class) to extract all text content from a node and its children.
     */
    private static class TextExtractorVisitor extends AbstractVisitor {
         private final StringBuilder buffer;

        TextExtractorVisitor(StringBuilder buffer) {
            this.buffer = buffer;
        }

        @Override public void visit(Text text) { buffer.append(text.getLiteral()); }
        @Override public void visit(Code code) { buffer.append(code.getLiteral()); }
        @Override public void visit(Emphasis emphasis) { visitChildren(emphasis); }
        @Override public void visit(StrongEmphasis strongEmphasis) { visitChildren(strongEmphasis); }
        @Override public void visit(Link link) { visitChildren(link); }
    }

    /**
     * Parses the hybrid status text ("yes" or "hybrid", case-insensitive).
     * @param text The text from the table cell.
     * @return true if the text indicates hybrid, false otherwise.
     */
    public static Boolean parseHybrid(String text) {
        if (text == null) return false;
        String lowerCaseText = text.trim().toLowerCase();
        return lowerCaseText.equals("yes") || lowerCaseText.equals("hybrid");
    }

    /**
     * Extracts the country name from a location string (assumes "City, Country" format).
     * @param location The location string.
     * @return The country name if found, otherwise null.
     */
    public static String extractCountryFromLocation(String location) {
        if (location != null && location.contains(",")) {
            String[] parts = location.split(",");
            if (parts.length > 1) {
                return parts[parts.length - 1].trim();
            }
        }
        return null;
    }
}

```

--------------------------------------------------------------------------------
/pom.xml:
--------------------------------------------------------------------------------

```
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.1</version>
        <relativePath/>
    </parent>
    <groupId>nano.dev</groupId>
    <artifactId>javaconf-mcp-server</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>javaconf-mcp-server</name>
    <description>MCP Server for Java Conference Information</description>

    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0-M6</spring-ai.version>
        <commonmark.version>0.22.0</commonmark.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-mcp-server-spring-boot-starter</artifactId>
                <version>${spring-ai.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-mcp-server-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- CommonMark for Markdown rendering -->
        <dependency>
            <groupId>org.commonmark</groupId>
            <artifactId>commonmark</artifactId>
            <version>${commonmark.version}</version>
        </dependency>
        <dependency>
            <groupId>org.commonmark</groupId>
            <artifactId>commonmark-ext-gfm-tables</artifactId>
            <version>${commonmark.version}</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <repositories>
        <repository>
            <id>spring-milestones</id>
            <name>Spring Milestones</name>
            <url>https://repo.spring.io/milestone</url>
            <snapshots>
                <enabled>false</enabled>
            </snapshots>
        </repository>
        <repository>
            <id>spring-snapshots</id>
            <name>Spring Snapshots</name>
            <url>https://repo.spring.io/snapshot</url>
            <releases>
                <enabled>false</enabled>
            </releases>
        </repository>
         <repository>
            <id>central-portal-snapshots</id>
            <name>Central Portal Snapshots</name>
            <url>https://central.sonatype.com/repository/maven-snapshots/</url>
            <releases>
                <enabled>false</enabled>
            </releases>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </repository>
    </repositories>

</project>

```

--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/MarkdownParsingHelper.java:
--------------------------------------------------------------------------------

```java
package nano.dev.javaConf.service;

import org.commonmark.node.Link;
import org.commonmark.node.Node;
import org.commonmark.node.Text;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.time.LocalDate;
import java.time.Month;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class MarkdownParsingHelper {

    private static final Logger log = LoggerFactory.getLogger(MarkdownParsingHelper.class);
    private static final Pattern DATE_PATTERN = Pattern.compile("(\w+)\s+(\d{1,2})(-\d{1,2})?"); // e.g., "January 10-12" or "Feb 5"

    /**
     * Extracts the URL from a Link node or its text child.
     */
    public static String extractLinkUrl(Node cellNode) {
        if (cellNode instanceof Link) {
            return ((Link) cellNode).getDestination();
        } else if (cellNode.getFirstChild() instanceof Link) {
            // Sometimes the link is wrapped inside another node (e.g., StrongEmphasis)
            return ((Link) cellNode.getFirstChild()).getDestination();
        } else if (cellNode.getFirstChild() instanceof Text) {
            // Check if the text itself is a URL (less common but possible)
            String text = ((Text) cellNode.getFirstChild()).getLiteral().trim();
            if (text.startsWith("http://") || text.startsWith("https://")) {
                return text;
            }
        }
        // Look deeper for Link nodes if nested
        Node node = cellNode.getFirstChild();
        while (node != null) {
            if (node instanceof Link) {
                return ((Link) node).getDestination();
            }
            // Recursively check children if necessary, though CommonMark usually flattens
            if (node.getFirstChild() != null) {
                String nestedUrl = extractLinkUrl(node); // Simplified recursion for example
                if (nestedUrl != null) {
                    return nestedUrl;
                }
            }
            node = node.getNext();
        }
        log.trace("Could not extract URL from node: {}", cellNode);
        return null;
    }

    /**
     * Extracts and parses the conference date string.
     */
    public static String extractConferenceDate(Node cellNode, int year) {
        String dateString = cellNode.getFirstChild() instanceof Text ? ((Text) cellNode.getFirstChild()).getLiteral().trim() : "N/A";
        return parseDate(dateString, year);
    }

    /**
     * Parses the date string into a standard format (e.g., "YYYY-MM-DD").
     * Handles formats like "Month DD-DD" or "Month DD".
     */
    private static String parseDate(String dateString, int year) {
        Matcher matcher = DATE_PATTERN.matcher(dateString);
        if (matcher.find()) {
            try {
                String monthStr = matcher.group(1);
                String dayStr = matcher.group(2);
                // Simple month abbreviation handling
                if (monthStr.length() > 3 && !monthStr.equalsIgnoreCase("sept")) {
                    monthStr = monthStr.substring(0, 3);
                } else if (monthStr.equalsIgnoreCase("sept")) {
                    monthStr = "Sep"; // Handle September abbreviation specifically
                }
                // Use DateTimeFormatter for robust month parsing
                DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("MMM", Locale.ENGLISH);
                Month month = Month.from(monthFormatter.parse(monthStr));
                int day = Integer.parseInt(dayStr);
                LocalDate startDate = LocalDate.of(year, month, day);
                return startDate.format(DateTimeFormatter.ISO_DATE); // Format as YYYY-MM-DD
            } catch (DateTimeParseException | NumberFormatException e) {
                log.warn("Could not parse date string: '{}' for year {}. Error: {}", dateString, year, e.getMessage());
                return dateString; // Return original string if parsing fails
            }
        }
        // Handle TBD or other non-standard formats
        if (dateString.equalsIgnoreCase("TBD")) {
            return "TBD";
        }
        log.warn("Date string '{}' did not match expected pattern for year {}", dateString, year);
        return dateString; // Return original string if no match
    }


    /**
     * Parses the hybrid status string. Returns true for "yes" or "hybrid", false otherwise.
     */
    public static boolean parseHybrid(Node cellNode) {
        if (cellNode != null && cellNode.getFirstChild() instanceof Text) {
            String hybridStatus = ((Text) cellNode.getFirstChild()).getLiteral().trim().toLowerCase();
            // Consider "yes" or "hybrid" as true, explicitly treat "no" as false, otherwise false.
            return hybridStatus.equals("yes") || hybridStatus.equals("hybrid");
        }
        return false; // Default to false if node is null or not Text
    }

} 
```

--------------------------------------------------------------------------------
/src/main/java/nano/dev/javaconf/service/MarkdownParsingService.java:
--------------------------------------------------------------------------------

```java
package nano.dev.javaconf.service;

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import nano.dev.javaconf.model.ConferenceInfo;
import nano.dev.javaconf.service.util.MarkdownParsingHelper;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TableBody;
import org.commonmark.ext.gfm.tables.TableRow;
import org.commonmark.ext.gfm.tables.TableCell;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.node.*;
import org.commonmark.parser.Parser;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Slf4j
@Service
public class MarkdownParsingService {

    private final Parser parser;

    private static final int CONF_NAME_INDEX = 0;
    private static final int LOCATION_INDEX = 1;
    private static final int HYBRID_INDEX = 2;
    private static final int CFP_LINK_INDEX = 4;
    private static final int EXPECTED_COLUMNS = 5;

    public MarkdownParsingService() {
        this.parser = Parser.builder()
                .extensions(Collections.singletonList(TablesExtension.create()))
                .build();
    }

    public List<ConferenceInfo> parse(String markdownContent) {
        if (!StringUtils.hasText(markdownContent)) {
            log.warn("Markdown content is empty or null. Skipping parsing.");
            return Collections.emptyList();
        }

        List<ConferenceInfo> allConferences = new ArrayList<>();
        try {
            Node document = parser.parse(markdownContent);
            Node node = document.getFirstChild();

            while (node != null) {
                if (node instanceof Heading heading && heading.getLevel() == 3) {
                    String headingText = MarkdownParsingHelper.extractText(heading).trim();
                    log.debug("Found H3 heading: '{}'", headingText);

                    // Check if the heading text looks like a 4-digit year
                    if (headingText.matches("^\\d{4}$")) {
                        processYearSection(heading, headingText).ifPresent(allConferences::addAll);
                    } else {
                         log.debug("Skipping H3 heading '{}' as it doesn't look like a year.", headingText);
                    }
                }
                node = node.getNext();
            }

            log.info("Parsed {} conferences in total from Markdown.", allConferences.size());
            return allConferences;

        } catch (Exception e) {
            log.error("Failed to parse markdown content due to an unexpected error", e);
            return Collections.emptyList();
        }
    }

    // Helper to process a section identified by a year heading
    private Optional<List<ConferenceInfo>> processYearSection(Heading yearHeading, String year) {
        log.info("Processing potential section for year: {}", year);
        Node nextNode = yearHeading.getNext();

        if (nextNode instanceof TableBlock tableBlock) {
            log.debug("Found TableBlock immediately after H3 heading for year {}", year);
            Node tableChild = tableBlock.getFirstChild();
            while (tableChild != null && !(tableChild instanceof TableBody)) {
                tableChild = tableChild.getNext();
            }

            if (tableChild instanceof TableBody tableBody) {
                return Optional.of(processTableRows(tableBody, year));
            } else {
                log.warn("Could not find TableBody within TableBlock for year {}", year);
                return Optional.empty();
            }
        } else {
            log.warn("No TableBlock found immediately after H3 heading for year {}", year);
            return Optional.empty();
        }
    }

    // Helper to process all rows within a TableBody using Streams
    private List<ConferenceInfo> processTableRows(TableBody tableBody, String currentYear) {
        return MarkdownParsingHelper.streamChildren(tableBody, TableRow.class)
                .map(row -> mapRowToConferenceInfo(row, currentYear))
                .flatMap(Optional::stream)
                .collect(Collectors.toList());
    }

    // Helper to map a single TableRow to ConferenceInfo, handling errors
    private Optional<ConferenceInfo> mapRowToConferenceInfo(TableRow row, String currentYear) {
        List<String> cellTexts = new ArrayList<>();
        List<Node> cellNodes = new ArrayList<>();
        Node cellNode = row.getFirstChild();
        while (cellNode instanceof TableCell) {
            cellTexts.add(MarkdownParsingHelper.extractText(cellNode).trim());
            cellNodes.add(cellNode);
            cellNode = cellNode.getNext();
        }
        log.trace("Processing row for year {} with {} cells: {}", currentYear, cellTexts.size(), cellTexts);

        if (cellTexts.size() < EXPECTED_COLUMNS) {
            log.warn("Skipping row in year {}: Insufficient cells (expected >= {}, found {}). Cell texts: {}",
                     currentYear, EXPECTED_COLUMNS, cellTexts.size(), cellTexts);
            return Optional.empty();
        }

        try {
            String confName = cellTexts.get(CONF_NAME_INDEX);
            String primaryLink = MarkdownParsingHelper.extractFirstLinkUrlFromNode(cellNodes.get(CONF_NAME_INDEX));
            String cfpStatusText = "-".equals(cellTexts.get(CFP_LINK_INDEX)) ? null : cellTexts.get(CFP_LINK_INDEX);
            String extractedCfpLink = MarkdownParsingHelper.extractFirstLinkUrlFromNode(cellNodes.get(CFP_LINK_INDEX));

            ConferenceInfo conference = ConferenceInfo.builder()
                    .year(Integer.parseInt(currentYear))
                    .conferenceName(confName)
                    .location(cellTexts.get(LOCATION_INDEX))
                    .isHybrid(MarkdownParsingHelper.parseHybrid(cellTexts.get(HYBRID_INDEX)))
                    .cfpStatus(cfpStatusText)
                    .cfpLink(extractedCfpLink)
                    .link(primaryLink)
                    .country(MarkdownParsingHelper.extractCountryFromLocation(cellTexts.get(LOCATION_INDEX)))
                    .build();

            log.debug("Mapped conference: {} from year {}", confName, currentYear);
            return Optional.of(conference);

        } catch (NumberFormatException nfe) {
             log.warn("Skipping row in year {}: Could not parse currentYear '{}' as integer.", currentYear, currentYear);
             return Optional.empty();
        } catch (IndexOutOfBoundsException iobe) {
            log.warn("Skipping row in year {}: Error accessing cell index. Columns expected >= {}, found {}. Error: {}. Row cell texts: {}",
                     currentYear, EXPECTED_COLUMNS, cellTexts.size(), iobe.getMessage(), cellTexts);
            return Optional.empty();
        } catch (Exception e) {
            log.warn("Skipping row in year {} due to generic parsing error: {}. Row cell texts: {}", currentYear, e.getMessage(), cellTexts, e);
            return Optional.empty();
        }
    }
}

```