Skip to content

Commit 0a4dec5

Browse files
committed
Add validation for prompt name uniqueness across the service.
- **New `PromptUniquenessValidator`**: Detects duplicate prompt names within services - **Updated examples**: Updated the examples to use the new naming convention. - **Only Service and Operations support**: Since we are not able to parse the prompts at resource level in the MCP Server, updated the trait selector to allow only service or operation level definition. Prevents runtime conflicts when services define prompts with the same name at different levels (e.g., service vs operation). This supports the established naming convention of `serviceName__prompt-name-as-defined-in-trait` which provides namespace disambiguation and aligns with MCP specification requirements.
1 parent ad4b5b2 commit 0a4dec5

File tree

13 files changed

+723
-7
lines changed

13 files changed

+723
-7
lines changed

examples/mcp-server/src/main/resources/software/amazon/smithy/java/example/server/mcp/main.smithy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use smithy.ai#prompts
1919
arguments: GetCodingStatisticsInput
2020
preferWhen: "User wants to analyze developer productivity, review coding activity, or understand technology usage patterns"
2121
}
22-
employee_lookup: {
22+
Test_employee: {
2323
description: "General employee lookup and information retrieval service"
2424
template: "This service provides employee information including personal details and coding statistics. Use get_employee_info for basic details or get_coding_stats for development metrics."
2525
preferWhen: "User needs any employee-related information or wants to understand available employee data"

mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/McpServer.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
package software.amazon.smithy.java.mcp.server;
77

8+
import static software.amazon.smithy.java.mcp.server.PromptLoader.normalize;
9+
810
import java.io.InputStream;
911
import java.io.OutputStream;
1012
import java.io.PrintWriter;
@@ -146,7 +148,7 @@ private void handleRequest(JsonRpcRequest req) {
146148
var promptName = req.getParams().getMember("name").asString();
147149
var promptArguments = req.getParams().getMember("arguments");
148150

149-
var prompt = prompts.get(promptName);
151+
var prompt = prompts.get(normalize(promptName));
150152

151153
if (prompt == null) {
152154
internalError(req, new RuntimeException("Prompt not found: " + promptName));

mcp/mcp-server/src/main/java/software/amazon/smithy/java/mcp/server/PromptLoader.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public static Map<String, Prompt> loadPrompts(Collection<Service> services) {
5454

5555
});
5656
for (Map.Entry<String, PromptTemplateDefinition> entry : promptDefinitions.entrySet()) {
57-
var promptName = entry.getKey().toLowerCase();
57+
var promptName = entry.getKey();
5858
var promptTemplateDefinition = entry.getValue();
5959
var templateString = promptTemplateDefinition.getTemplate();
6060

@@ -76,8 +76,20 @@ public static Map<String, Prompt> loadPrompts(Collection<Service> services) {
7676
: List.of())
7777
.build();
7878

79+
var normalizedName = normalize(promptName);
80+
81+
if (prompts.containsKey(normalizedName)) {
82+
var existingPrompt = prompts.get(normalizedName);
83+
throw new RuntimeException(String.format(
84+
"Duplicate normalized prompt name '%s' found. " +
85+
"Original name '%s' conflicts with previously registered name '%s'.",
86+
normalizedName,
87+
promptName,
88+
existingPrompt.promptInfo().getName()));
89+
}
90+
7991
prompts.put(
80-
promptName,
92+
normalizedName,
8193
new Prompt(promptInfo, finalTemplateString));
8294
}
8395
}
@@ -118,4 +130,8 @@ public static List<PromptArgument> convertArgumentShapeToPromptArgument(Schema a
118130

119131
return promptArguments;
120132
}
133+
134+
public static String normalize(String promptName) {
135+
return promptName.toLowerCase();
136+
}
121137
}

mcp/mcp-server/src/test/java/software/amazon/smithy/java/mcp/server/McpServerTest.java

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,18 @@ void testPromptsList() {
395395
assertEquals("search_users", servicePromptMap.get("name").asString());
396396
assertEquals("Test Template", servicePromptMap.get("description").asString());
397397
assertTrue(servicePromptMap.get("arguments").asList().isEmpty());
398+
399+
// Verify that all prompt names are normalized to lowercase
400+
var promptNames = prompts.stream()
401+
.map(p -> p.asStringMap().get("name").asString())
402+
.toList();
403+
assertTrue(promptNames.contains("search_users"));
404+
assertTrue(promptNames.contains("perform_operation"));
405+
406+
// Verify all names are lowercase (normalized)
407+
for (String name : promptNames) {
408+
assertEquals(name.toLowerCase(), name, "Prompt name should be normalized to lowercase: " + name);
409+
}
398410
}
399411

400412
@Test
@@ -430,6 +442,76 @@ void testPromptsGetWithValidPrompt() {
430442
assertEquals("Search for if many results expected.", content.get("text").asString());
431443
}
432444

445+
@Test
446+
void testPromptsGetWithDifferentCasing() {
447+
server = McpServer.builder()
448+
.input(input)
449+
.output(output)
450+
.addService("test-mcp",
451+
ProxyService.builder()
452+
.service(ShapeId.from("smithy.test#TestService"))
453+
.proxyEndpoint("http://localhost")
454+
.model(MODEL)
455+
.build())
456+
.build();
457+
458+
server.start();
459+
460+
// Test with uppercase prompt name
461+
write("prompts/get",
462+
Document.of(Map.of(
463+
"name",
464+
Document.of("SEARCH_USERS"))));
465+
var response = read();
466+
var result = response.getResult().asStringMap();
467+
468+
assertEquals("Test Template", result.get("description").asString());
469+
var messages = result.get("messages").asList();
470+
assertEquals(1, messages.size());
471+
472+
var message = messages.get(0).asStringMap();
473+
assertEquals("user", message.get("role").asString());
474+
var content = message.get("content").asStringMap();
475+
assertEquals("text", content.get("type").asString());
476+
assertEquals("Search for if many results expected.", content.get("text").asString());
477+
478+
// Test with mixed case prompt name
479+
write("prompts/get",
480+
Document.of(Map.of(
481+
"name",
482+
Document.of("Search_Users"))));
483+
response = read();
484+
result = response.getResult().asStringMap();
485+
486+
assertEquals("Test Template", result.get("description").asString());
487+
messages = result.get("messages").asList();
488+
assertEquals(1, messages.size());
489+
490+
message = messages.get(0).asStringMap();
491+
assertEquals("user", message.get("role").asString());
492+
content = message.get("content").asStringMap();
493+
assertEquals("text", content.get("type").asString());
494+
assertEquals("Search for if many results expected.", content.get("text").asString());
495+
496+
// Test with perform_operation prompt in different case
497+
write("prompts/get",
498+
Document.of(Map.of(
499+
"name",
500+
Document.of("PERFORM_OPERATION"))));
501+
response = read();
502+
result = response.getResult().asStringMap();
503+
504+
assertEquals("perform operation", result.get("description").asString());
505+
messages = result.get("messages").asList();
506+
assertEquals(1, messages.size());
507+
508+
message = messages.get(0).asStringMap();
509+
assertEquals("user", message.get("role").asString());
510+
content = message.get("content").asStringMap();
511+
assertEquals("text", content.get("type").asString());
512+
assertEquals("use tool TestOperation with some information.", content.get("text").asString());
513+
}
514+
433515
@Test
434516
void testPromptsGetWithInvalidPrompt() {
435517
server = McpServer.builder()
@@ -452,6 +534,15 @@ void testPromptsGetWithInvalidPrompt() {
452534
var response = read();
453535
assertNotNull(response.getError());
454536
assertTrue(response.getError().getMessage().contains("Prompt not found: nonexistent_prompt"));
537+
538+
// Test with invalid prompt in different case - should still fail
539+
write("prompts/get",
540+
Document.of(Map.of(
541+
"name",
542+
Document.of("NONEXISTENT_PROMPT"))));
543+
response = read();
544+
assertNotNull(response.getError());
545+
assertTrue(response.getError().getMessage().contains("Prompt not found: NONEXISTENT_PROMPT"));
455546
}
456547

457548
@Test

0 commit comments

Comments
 (0)