Skip to content

Commit 9ca2722

Browse files
✨ feat(mcp): GetAllDwellings read slice | add MCP server resource
1 parent c537280 commit 9ca2722

3 files changed

Lines changed: 249 additions & 14 deletions

File tree

.claude/tasks/0001_introduce_mcp_server.md

Lines changed: 121 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ The MCP server will expose the rich domain model through standardized MCP resour
5252
- [x] Establish patterns and conventions
5353

5454
### 📈 Phase 2: Expand Creature Recruitment Context
55-
- [ ] **creaturerecruitment/write/recruitcreature**: Recruit creature tools/resources
55+
- [x] **creaturerecruitment/write/recruitcreature**: Recruit creature tools/resources
5656
- [ ] **creaturerecruitment/write/changeavailablecreatures**: Availability tools/resources
5757
- [ ] **creaturerecruitment/read/getdwellingbyid**: Single dwelling query resources
58-
- [ ] **creaturerecruitment/read/getalldwellings**: Dwellings list query resources
58+
- [x] **creaturerecruitment/read/getalldwellings**: Dwellings list query resources
5959
- [ ] **creaturerecruitment**: Shared recruitment strategy prompts
6060

6161
### 🔄 Phase 3: Replicate to Other Contexts
@@ -254,17 +254,128 @@ return commandGateway.send(command, GameMetaData.with(gameId, playerId))
254254
));
255255
```
256256

257+
### MCP Resource Implementation Pattern
258+
259+
Based on the successful implementation of `GetAllDwellingsMcp` resource, here's the established pattern for creating MCP resources:
260+
261+
#### 1. **File Location & Naming**
262+
- Each read slice gets its own MCP class with suffix `Mcp`
263+
- Location: `{context}/read/{slice}/{SliceName}Mcp.java`
264+
- Example: `creaturerecruitment/read/getalldwellings/GetAllDwellingsMcp.java`
265+
266+
#### 2. **Class Structure**
267+
```java
268+
@Component
269+
public class GetAllDwellingsMcp {
270+
271+
private final QueryGateway queryGateway;
272+
private final ObjectMapper objectMapper;
273+
274+
public GetAllDwellingsMcp(QueryGateway queryGateway, ObjectMapper objectMapper) {
275+
this.queryGateway = queryGateway;
276+
this.objectMapper = objectMapper;
277+
}
278+
279+
@Bean
280+
public List<McpServerFeatures.SyncResourceSpecification> getAllDwellingsResource() {
281+
// Resource definition and handler implementation
282+
}
283+
}
284+
```
285+
286+
#### 3. **Key Design Decisions for Resources**
287+
- **Use `@Component` and `@Bean`** for resource specification
288+
- **Return `List<McpServerFeatures.SyncResourceSpecification>`** from bean methods
289+
- **Follow MCP URI standard** with custom scheme (e.g., `heroesofddd://games/{gameId}/dwellings`)
290+
- **Include proper MCP annotations** with audience and priority
291+
- **Reuse existing Query/QueryGateway** infrastructure
292+
- **Provide structured JSON responses** with metadata
293+
294+
#### 4. **URI Pattern**
295+
```java
296+
// Correct MCP URI with custom scheme following REST-like pattern
297+
"heroesofddd://games/{gameId}/dwellings"
298+
299+
// Path parameter extraction
300+
private Optional<String> extractGameId(String uri) {
301+
final String scheme = "heroesofddd://";
302+
final String expectedPath = "games";
303+
final String expectedEndpoint = "dwellings";
304+
305+
if (!uri.startsWith(scheme)) {
306+
return Optional.empty();
307+
}
308+
309+
String[] pathSegments = uri.substring(scheme.length()).split("/");
310+
311+
if (pathSegments.length != 3 ||
312+
!expectedPath.equals(pathSegments[0]) ||
313+
!expectedEndpoint.equals(pathSegments[2])) {
314+
return Optional.empty();
315+
}
316+
317+
String gameId = pathSegments[1];
318+
return gameId.trim().isEmpty() ? Optional.empty() : Optional.of(gameId);
319+
}
320+
```
321+
322+
#### 5. **Resource Definition Pattern**
323+
```java
324+
var dwellingsResource = new McpSchema.Resource(
325+
"heroesofddd://games/{gameId}/dwellings", // URI following MCP standard
326+
"All Dwellings", // Human-readable name
327+
"Provides access to all dwellings in a game...", // Description
328+
"application/json", // MIME type
329+
new McpSchema.Annotations(
330+
List.of(McpSchema.Role.USER, McpSchema.Role.ASSISTANT), // Audience
331+
0.8 // Priority (0.0-1.0)
332+
)
333+
);
334+
```
335+
336+
#### 6. **Response Pattern**
337+
```java
338+
// Success Response
339+
String jsonContent = formatDwellings(result);
340+
return new McpSchema.ReadResourceResult(
341+
List.of(new McpSchema.TextResourceContents(
342+
request.uri(),
343+
"application/json",
344+
jsonContent
345+
))
346+
);
347+
348+
// Error Response
349+
String errorContent = String.format("""
350+
{
351+
"error": "Failed to retrieve dwellings: %s",
352+
"dwellings": []
353+
}
354+
""", e.getMessage());
355+
return new McpSchema.ReadResourceResult(
356+
List.of(new McpSchema.TextResourceContents(
357+
request.uri(),
358+
"application/json",
359+
errorContent
360+
))
361+
);
362+
```
363+
257364
### Next Implementation Steps
258365

259-
With the proven pattern established, implement remaining tools following the same structure:
366+
With proven patterns established for both Tools and Resources, implement remaining components:
367+
368+
**Completed:**
369+
1.**creaturerecruitment/write/recruitcreature/RecruitCreatureMcp.java** (Tool)
370+
2.**creaturerecruitment/read/getalldwellings/GetAllDwellingsMcp.java** (Resource)
260371

261-
1. **creaturerecruitment/write/recruitcreature/ModelContextProtocol.java**
262-
2. **creaturerecruitment/write/changeavailablecreatures/ModelContextProtocol.java**
263-
3. **creaturerecruitment/read/getdwellingbyid/ModelContextProtocol.java** (Resources)
264-
4. **creaturerecruitment/read/getalldwellings/ModelContextProtocol.java** (Resources)
372+
**Remaining:**
373+
3. **creaturerecruitment/write/changeavailablecreatures** - Follow Tool pattern
374+
4. **creaturerecruitment/read/getdwellingbyid** - Follow Resource pattern
265375

266376
Each implementation should:
267-
- Follow the established class structure
377+
- Follow the established class structure patterns
268378
- Use appropriate parameter validation
269-
- Maintain consistent response formats
270-
- Leverage existing domain infrastructure
379+
- Maintain consistent response formats
380+
- Leverage existing domain infrastructure
381+
- Follow MCP URI standards with custom scheme

generated-requests.http

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
@serverPort = 3773
2+
13
### BuildDwelling
24
@gameId = scenario-1
35
@playerId = player-1
46
@dwellingId = dwelling-1
57

68
### BuildDwelling
7-
PUT http://localhost:8080/games/{{gameId}}/dwellings/{{dwellingId}}
9+
PUT http://localhost:{{serverPort}}/games/{{gameId}}/dwellings/{{dwellingId}}
810
Content-Type: application/json
911
X-Player-Id: {{playerId}}
1012

@@ -17,7 +19,7 @@ X-Player-Id: {{playerId}}
1719
}
1820

1921
### IncreaseAvailableCreatures
20-
PUT http://localhost:8080/games/{{gameId}}/dwellings/{{dwellingId}}/available-creatures-increases
22+
PUT http://localhost:{{serverPort}}/games/{{gameId}}/dwellings/{{dwellingId}}/available-creatures-increases
2123
Content-Type: application/json
2224
X-Player-Id: {{playerId}}
2325

@@ -27,7 +29,7 @@ X-Player-Id: {{playerId}}
2729
}
2830

2931
### RecruitCreature
30-
PUT http://localhost:8080/games/{{gameId}}/dwellings/{{dwellingId}}/creature-recruitments
32+
PUT http://localhost:{{serverPort}}/games/{{gameId}}/dwellings/{{dwellingId}}/creature-recruitments
3133
Content-Type: application/json
3234
X-Player-Id: {{playerId}}
3335

@@ -42,6 +44,6 @@ X-Player-Id: {{playerId}}
4244
}
4345

4446
### Dwelling read model
45-
GET http://localhost:8080/games/{{gameId}}/dwellings/{{dwellingId}}
47+
GET http://localhost:{{serverPort}}/games/{{gameId}}/dwellings/{{dwellingId}}
4648
Content-Type: application/json
4749

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.dddheroes.heroesofddd.creaturerecruitment.read.getalldwellings;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import io.modelcontextprotocol.server.McpServerFeatures;
5+
import io.modelcontextprotocol.spec.McpSchema;
6+
import org.axonframework.queryhandling.QueryGateway;
7+
import org.springframework.context.annotation.Bean;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.util.List;
11+
import java.util.Map;
12+
import java.util.Optional;
13+
14+
@Component
15+
public class GetAllDwellingsMcp {
16+
17+
private final QueryGateway queryGateway;
18+
private final ObjectMapper objectMapper;
19+
20+
public GetAllDwellingsMcp(QueryGateway queryGateway, ObjectMapper objectMapper) {
21+
this.queryGateway = queryGateway;
22+
this.objectMapper = objectMapper;
23+
}
24+
25+
@Bean
26+
public List<McpServerFeatures.SyncResourceSpecification> getAllDwellingsResource() {
27+
var dwellingsResource = new McpSchema.Resource(
28+
"heroesofddd://games/{gameId}/dwellings",
29+
"All Dwellings",
30+
"Provides access to all dwellings in a game with their creature availability and recruitment costs. Uses path parameter gameId.",
31+
"application/json",
32+
new McpSchema.Annotations(
33+
List.of(McpSchema.Role.USER, McpSchema.Role.ASSISTANT),
34+
0.8
35+
)
36+
);
37+
38+
var resourceSpecification = new McpServerFeatures.SyncResourceSpecification(
39+
dwellingsResource,
40+
(exchange, request) -> {
41+
try {
42+
var gameId = extractGameId(request.uri())
43+
.orElseThrow(() -> new IllegalArgumentException("gameId parameter is required in URI (e.g., heroesofddd://games/game-123/dwellings)"));
44+
45+
var query = GetAllDwellings.query(gameId);
46+
var result = queryGateway.query(query, GetAllDwellings.Result.class).get();
47+
48+
var jsonContent = formatDwellings(result);
49+
50+
return new McpSchema.ReadResourceResult(
51+
List.of(new McpSchema.TextResourceContents(
52+
request.uri(),
53+
"application/json",
54+
jsonContent
55+
))
56+
);
57+
} catch (Exception e) {
58+
String errorContent = String.format("""
59+
{
60+
"error": "Failed to retrieve dwellings: %s",
61+
"dwellings": []
62+
}
63+
""", e.getMessage());
64+
65+
return new McpSchema.ReadResourceResult(
66+
List.of(new McpSchema.TextResourceContents(
67+
request.uri(),
68+
"application/json",
69+
errorContent
70+
))
71+
);
72+
}
73+
}
74+
);
75+
76+
return List.of(resourceSpecification);
77+
}
78+
79+
private Optional<String> extractGameId(String uri) {
80+
// Expected URI pattern: heroesofddd://games/{gameId}/dwellings
81+
final String scheme = "heroesofddd://";
82+
final String expectedPath = "games";
83+
final String expectedEndpoint = "dwellings";
84+
85+
if (!uri.startsWith(scheme)) {
86+
return Optional.empty();
87+
}
88+
89+
String[] pathSegments = uri.substring(scheme.length()).split("/");
90+
91+
if (pathSegments.length != 3 ||
92+
!expectedPath.equals(pathSegments[0]) ||
93+
!expectedEndpoint.equals(pathSegments[2])) {
94+
return Optional.empty();
95+
}
96+
97+
String gameId = pathSegments[1];
98+
return gameId.trim().isEmpty() ? Optional.empty() : Optional.of(gameId);
99+
}
100+
101+
private String formatDwellings(GetAllDwellings.Result result) {
102+
try {
103+
var dwellings = result.dwellings();
104+
105+
var response = Map.of(
106+
"dwellings", dwellings,
107+
"count", dwellings.size(),
108+
"timestamp", System.currentTimeMillis()
109+
);
110+
111+
return objectMapper.writeValueAsString(response);
112+
} catch (Exception e) {
113+
return String.format("""
114+
{
115+
"error": "Failed to serialize dwellings: %s",
116+
"dwellings": []
117+
}
118+
""", e.getMessage());
119+
}
120+
}
121+
122+
}

0 commit comments

Comments
 (0)