Now that the bridge is built and our Java app can talk to the GitHub MCP server, we need a way to actually trigger work.
Enter the Fetcher.
Initially, I wanted to use the standard MCP tools to get issues based on a repo basis. I then learned that projects is user-based, not repo-based. (kind of my eureka moment) So I decided to shift gears and focus my attention on getting the project info. However, I ran into a significant hurdle: GitHub ProjectsV2. More on that later.
🕵️ The Search for Work
The Fetcher is a scheduled service in Spring Boot that acts as the eyes of the system. Every 30 minutes, it looks around and asks: "Got any cool new stuff?"
Scheduled(fixedRate = 18000000, initialDelay = 5000) // 30 min, delay 5s
public void checkForAvailableWork() {
logger.info(" --Checking for available work-- ");
// get all available tasks in the project setup
String availableTasks = getGithubTasks();
// convert them to githubTasks with types
List<GithubTask> githubTasks = GithubParser.parseTasksFromProject(availableTasks);
if (githubTasks.isEmpty()) {
logger.info(" --Currently no tasks to be done, will check again in 30 min!-- ");
}
// send events
githubTasks.forEach(task -> publishEvent(task, task.type()));
}The response will look, partially at least, something like this:
"items": {
"nodes": [
{
"id": "PVTI_l... (item id)",
"fieldValues": {
"nodes": [
{},
{},
{},
{
"name": "Backlog",
"field": {
"name": "Status"
}
}
]
},
"content": {
"id": "I_kw...(issue Id)",
"number": 6,
"title": "Do something magical and unheard of",
"state": "OPEN",
"body": "Write tests."
}
}
]
}Take specific notice in the difference between the item ID and the issue ID. They are different!
The itemId is the id of the task in the project, while the issueId is the id of the issue in the repository.
It then parses that json response and converts it into a list of GithubTask objects. Parsing is easy, you start by using mapper.readTree(jsonResponse). Then you get the nodes and iterate over them. You can find the full implementation in the GithubParser class:
/**
* Parses the jsonResponse into GithubTasks
*/
public static List<GithubTask> parseTasksFromProject(String jsonResponse) {
try {
JsonNode root = mapper.readTree(jsonResponse);
JsonNode tasks = root.at("/data/node/items/nodes");
if (tasks.isMissingNode() || !tasks.isArray()) {
return Collections.emptyList();
}
List<GithubTask> parsedTasks = new ArrayList<>();
for (JsonNode task : tasks) {
JsonNode fieldValueNodes = task.at("/fieldValues/nodes");
for (JsonNode node : fieldValueNodes) {
switch(node.path("name").asText()) {
case "Backlog" :
parsedTasks.add(parseTaskFromJsonNode(task, "ANALYSIS"));
break;
case "Todo" :
parsedTasks.add(parseTaskFromJsonNode(task, "CODER"));
break;
}
}
}
return parsedTasks;
} catch (Exception e) {
logger.error("error parsing tasks from project: {}", e.getMessage());
return Collections.emptyList();
}
}The parseTasksFromProject method is a bit verbose, I know. I'm planning to refactor it in the future using a dedicated Pojo or looking into a library like Apollo. But it gets the job done and importantly, it sets the type of task based on the column it is in.
🛑 The "403 Forbidden" Headache
Like I mentioned earlier, I ran into a significant hurdle: GitHub ProjectsV2. GitHub’s newer Project boards (V2) rely heavily on GraphQL. While the standard GitHub MCP server is excellent for repository-level tasks, it struggles with the nuances of ProjectV2 metadata via standard REST calls.
When I tried to use the MCP tools to fetch project-specific items, I kept hitting permission errors or getting incomplete data. The MCP server was trying to call REST endpoints, but the data I needed lived in the GraphQL schema.
đź’ˇ The Workaround: Custom GraphQL Integration
Instead of waiting for the MCP server to catch up, I decided to talk directly to GitHub’s GraphQL endpoint. Ideally this will originate from the Github MCP server in the future, but for now this works. (6-7... I'm looking at you GitHub!)
Did i use that right? Anyway.. continuing on.
Using restClient to call the GraphQL endpoint, I fetched the tasks on my board using the following query:
query getTasksFromProjects($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
title
items(first: 20) {
nodes {
id
fieldValues(first: 10) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field {
... on ProjectV2SingleSelectField {
name
}
}
}
}
}
content {
... on Issue {
id
number
title
state
body
repository {
name
owner {
login
}
}
}
}
}
}
}
}
}You can then inject that into the service using Spring's value annotation:
@Value("classpath:graphql/fetch-tasks-from-projects.graphql")
private Resource fetchTasksFromProjects;And the call itself:
// custom graphql GitHub project task fetcher
private String getGithubTasks(){
try {
Map<String, Object> requestBody = Map.of(
"query", getQuery(fetchTasksFromProjects),
"variables", Map.of("projectId", projectId));
return restClient.post()
.uri("https://api.github.com/graphql")
.header("Authorization", "bearer " + apiKey)
.header("Content-Type", "application/json")
.body(requestBody)
.retrieve()
.body(String.class);
} catch (IOException e) {
logger.error("Failure trying to fetch tasks", e);
throw new RuntimeException(e);
}
}🎯 Precision Targeting
By doing this, the Fetcher can see exactly which column an issue is in (e.g., "Todo" or "Backlog"). This allows the Orchestrator to be smart:
- If an item is in Backlog, it assigns the Analyst Agent.
- If an item is in Todo (and has been analyzed), it assigns the Coding Agent.
What’s Next?
We have the connection (Part 2) and the search capability (Part 3). In the next post, we finally meet the Orchestrator and see how we use Java 21's Virtual Threads to keep these agents running in parallel without breaking a sweat!
