Skip to content

Commit 7dd1a52

Browse files
committed
fix: refine MCP tool annotations
1 parent a6b91fc commit 7dd1a52

29 files changed

Lines changed: 299 additions & 63 deletions

PLAN.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,39 @@
44

55
Prepare StreamNative MCP Server for Claude connector submission and review.
66

7+
## Current review follow-up: annotation helper granularity
8+
9+
Reviewer feedback is valid: `toolannotations.ReadOnly` and `toolannotations.Destructive` currently call `mcp.WithToolAnnotation(...)`, replacing the whole annotation struct and dropping mcp-go defaults for `idempotentHint` and `openWorldHint`. The helper also collapses all side effects into `Destructive`, so local session mutations and reversible lifecycle operations look equivalent to delete/apply operations.
10+
11+
Implementation status: implemented in current work.
12+
13+
1. Replaced whole-annotation assignment helpers with composed field setters so unspecified hints keep mcp-go defaults unless explicitly changed.
14+
2. Exposed helper categories:
15+
- `ReadOnly(title)` for non-mutating external reads: read-only true, destructive false, idempotent true, open-world true.
16+
- `ExternalRead(title)` as explicit alias for external read tools when caller intent should be obvious.
17+
- `LocalSessionMutation(title)` for session/context state changes: read-only false, destructive false, idempotent false by default, open-world false.
18+
- `Mutating(title, destructive, idempotent)` for external writes/side effects: read-only false, caller-controlled destructive/idempotent, open-world true.
19+
3. Kept `Destructive(title)` as compatibility wrapper over `Mutating(title, true, false)` while migrating call sites to precise helpers.
20+
4. Updated context tools to use `LocalSessionMutation`; updated operation-mode helper to derive destructive/idempotent from `OperationSpec` where available instead of treating all writes as destructive.
21+
5. Extended annotation tests to assert `idempotentHint` and `openWorldHint` for static/context/builder tools, not only read/destructive.
22+
23+
Risks:
24+
25+
- Tool annotation surface changes are runtime-visible to MCP clients and Claude review.
26+
- Incorrect idempotency classification can mislead clients; delete/remove/reset may be idempotent only depending backend behavior.
27+
- Broad migration across all builders risks noisy diff; recommended first patch helper + high-signal call sites, then operation registry cleanup separately.
28+
29+
Recommended validation:
30+
31+
```bash
32+
go test -race ./pkg/mcp/... ./pkg/mcp/builders/... ./pkg/mcp/pftools/...
33+
go test -race ./...
34+
go fmt ./...
35+
go mod tidy
36+
golangci-lint run --timeout=3m
37+
make build
38+
```
39+
740
## Current review follow-up: operation spec registry
841

942
Reviewer feedback is valid: current branch split read/write tool names, but duplicated `toolMode` helpers and per-builder write-operation maps still create scatter-shot maintenance. Adding one operation can require enum updates, write map updates, handler switch updates, docs, and compliance-test classification. `validateModeOperation` also classifies any operation missing from the write map as read, so an unclassified future write can pass read-mode validation until the handler switch rejects or mishandles it.

pkg/mcp/builders/kafka/annotation_compliance_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,23 @@ func TestKafkaToolAnnotationCompliance(t *testing.T) {
5454
require.NotEmpty(t, tool.Annotations.Title, tool.Name)
5555
require.NotNil(t, tool.Annotations.ReadOnlyHint, tool.Name)
5656
require.NotNil(t, tool.Annotations.DestructiveHint, tool.Name)
57+
require.NotNil(t, tool.Annotations.IdempotentHint, tool.Name)
58+
require.NotNil(t, tool.Annotations.OpenWorldHint, tool.Name)
5759
require.LessOrEqual(t, len(tool.Name), 64, tool.Name)
5860

5961
isRead := strings.HasSuffix(tool.Name, "_read")
6062
isWrite := strings.HasSuffix(tool.Name, "_write") || strings.Contains(tool.Name, "produce") || strings.Contains(tool.Name, "consume")
6163
if isRead {
6264
require.True(t, *tool.Annotations.ReadOnlyHint, tool.Name)
6365
require.False(t, *tool.Annotations.DestructiveHint, tool.Name)
66+
require.True(t, *tool.Annotations.IdempotentHint, tool.Name)
67+
require.True(t, *tool.Annotations.OpenWorldHint, tool.Name)
6468
}
6569
if isWrite {
6670
require.False(t, *tool.Annotations.ReadOnlyHint, tool.Name)
6771
require.True(t, *tool.Annotations.DestructiveHint, tool.Name)
72+
require.False(t, *tool.Annotations.IdempotentHint, tool.Name)
73+
require.True(t, *tool.Annotations.OpenWorldHint, tool.Name)
6874
}
6975
assertOperationEnumMode(t, tool.Name, tool.InputSchema.Properties["operation"])
7076
}

pkg/mcp/builders/kafka/connect.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (b *KafkaConnectToolBuilder) buildKafkaConnectTool(mode toolMode) mcp.Tool
113113
"- get: Retrieve detailed information about a Kafka Connect cluster or specific connector."
114114
operationEnum := kafkaConnectOperationSpecs.NamesForMode(mode)
115115
toolName := "kafka_admin_connect_read"
116-
annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Connect", "Manage Kafka Connect")
116+
annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Connect", "Manage Kafka Connect", kafkaConnectOperationSpecs)
117117
if isToolModeWrite(mode) {
118118
resourceDesc = "Resource to operate on. Available resources:\n" +
119119
"- connector: A single Kafka Connect connector instance that moves data between Kafka and external systems."

pkg/mcp/builders/kafka/groups.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func (b *KafkaGroupsToolBuilder) buildKafkaGroupsTool(mode toolMode) mcp.Tool {
104104
"- offsets: Get offsets for a specific consumer group"
105105
operationEnum := kafkaGroupOperationSpecs.NamesForMode(mode)
106106
toolName := "kafka_admin_groups_read"
107-
annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Consumer Groups", "Manage Kafka Consumer Groups")
107+
annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Consumer Groups", "Manage Kafka Consumer Groups", kafkaGroupOperationSpecs)
108108
if isToolModeWrite(mode) {
109109
operationDesc = "Operation to perform. Available operations:\n" +
110110
"- remove-members: Remove specific members from a Consumer Group to force rebalancing or troubleshoot issues\n" +

pkg/mcp/builders/kafka/schema_registry.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ func (b *KafkaSchemaRegistryToolBuilder) buildKafkaSchemaRegistryTool(mode toolM
108108
"- get: Get a subject's latest schema, a specific version, or compatibility level"
109109
operationEnum := kafkaSchemaRegistryOperationSpecs.NamesForMode(mode)
110110
toolName := "kafka_admin_sr_read"
111-
annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Schema Registry", "Manage Kafka Schema Registry")
111+
annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Schema Registry", "Manage Kafka Schema Registry", kafkaSchemaRegistryOperationSpecs)
112112
if isToolModeWrite(mode) {
113113
resourceDesc = "Resource to operate on. Available resources:\n" +
114114
"- subject: A specific schema subject to register or delete\n" +

pkg/mcp/builders/kafka/topics.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func (b *KafkaTopicsToolBuilder) buildKafkaTopicsTool(mode toolMode) mcp.Tool {
103103
"- metadata: Get metadata for a specific topic\n"
104104
operationEnum := kafkaTopicOperationSpecs.NamesForMode(mode)
105105
toolName := "kafka_admin_topics_read"
106-
annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Topics", "Manage Kafka Topics")
106+
annotation := builders.ToolAnnotationForMode(mode, "Read Kafka Topics", "Manage Kafka Topics", kafkaTopicOperationSpecs)
107107
if isToolModeWrite(mode) {
108108
operationDesc = "Operation to perform. Available operations:\n" +
109109
"- create: Create a new topic with specified partitions, replication factor, and optional configs\n" +

pkg/mcp/builders/operation_annotations.go

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,36 @@ import (
2121

2222
// ToolAnnotationForMode selects Claude connector safety annotations from the
2323
// operation mode so schemas, validation, and annotations share the same mode
24-
// vocabulary.
25-
func ToolAnnotationForMode(mode OperationMode, readTitle, writeTitle string) mcp.ToolOption {
26-
if mode == OperationModeWrite {
27-
return toolannotations.Destructive(writeTitle)
24+
// vocabulary. If operation metadata is supplied, write-tool destructive and
25+
// idempotent hints are derived from the write specs instead of from mode alone.
26+
func ToolAnnotationForMode(mode OperationMode, readTitle, writeTitle string, registries ...OperationRegistry) mcp.ToolOption {
27+
if mode != OperationModeWrite {
28+
return toolannotations.ExternalRead(readTitle)
2829
}
29-
return toolannotations.ReadOnly(readTitle)
30+
31+
destructive, idempotent := writeAnnotationHints(registries...)
32+
return toolannotations.Mutating(writeTitle, destructive, idempotent)
33+
}
34+
35+
func writeAnnotationHints(registries ...OperationRegistry) (destructive bool, idempotent bool) {
36+
if len(registries) == 0 {
37+
return true, false
38+
}
39+
40+
foundWriteSpec := false
41+
allIdempotent := true
42+
for _, registry := range registries {
43+
for _, spec := range registry.SpecsForMode(OperationModeWrite) {
44+
foundWriteSpec = true
45+
destructive = destructive || spec.Destructive
46+
allIdempotent = allIdempotent && spec.Idempotent
47+
}
48+
}
49+
if !foundWriteSpec {
50+
return true, false
51+
}
52+
53+
// A multi-operation tool is idempotent only when every exposed write
54+
// operation is idempotent.
55+
return destructive, allIdempotent
3056
}

pkg/mcp/builders/operations.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,24 @@ func (r OperationRegistry) Names() []string {
6868
return names
6969
}
7070

71-
// NamesForMode returns operation names for one mode in registry order.
72-
func (r OperationRegistry) NamesForMode(mode OperationMode) []string {
73-
names := make([]string, 0, len(r))
71+
// SpecsForMode returns operation specs for one mode in registry order.
72+
func (r OperationRegistry) SpecsForMode(mode OperationMode) []OperationSpec {
73+
specs := make([]OperationSpec, 0, len(r))
7474
for _, spec := range r {
7575
if spec.Mode == mode {
76-
names = append(names, spec.Name)
76+
specs = append(specs, spec)
7777
}
7878
}
79+
return specs
80+
}
81+
82+
// NamesForMode returns operation names for one mode in registry order.
83+
func (r OperationRegistry) NamesForMode(mode OperationMode) []string {
84+
specs := r.SpecsForMode(mode)
85+
names := make([]string, 0, len(specs))
86+
for _, spec := range specs {
87+
names = append(names, spec.Name)
88+
}
7989
return names
8090
}
8191

pkg/mcp/builders/operations_test.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,34 @@ func TestOperationRegistryModeEnumsAndValidation(t *testing.T) {
3737
}
3838

3939
func TestToolAnnotationForMode(t *testing.T) {
40-
read := ToolAnnotationForMode(OperationModeRead, "Read Things", "Manage Things")
41-
write := ToolAnnotationForMode(OperationModeWrite, "Read Things", "Manage Things")
40+
registry := OperationRegistry{
41+
{Name: "list", Mode: OperationModeRead},
42+
{Name: "update", Mode: OperationModeWrite, Destructive: false, Idempotent: true},
43+
}
44+
45+
read := ToolAnnotationForMode(OperationModeRead, "Read Things", "Manage Things", registry)
46+
write := ToolAnnotationForMode(OperationModeWrite, "Read Things", "Manage Things", registry)
4247

4348
readTool := mcp.NewTool("read", read)
4449
writeTool := mcp.NewTool("write", write)
4550

4651
require.Equal(t, "Read Things", readTool.Annotations.Title)
4752
require.NotNil(t, readTool.Annotations.ReadOnlyHint)
4853
require.NotNil(t, readTool.Annotations.DestructiveHint)
54+
require.NotNil(t, readTool.Annotations.IdempotentHint)
55+
require.NotNil(t, readTool.Annotations.OpenWorldHint)
4956
require.True(t, *readTool.Annotations.ReadOnlyHint)
5057
require.False(t, *readTool.Annotations.DestructiveHint)
58+
require.True(t, *readTool.Annotations.IdempotentHint)
59+
require.True(t, *readTool.Annotations.OpenWorldHint)
5160

5261
require.Equal(t, "Manage Things", writeTool.Annotations.Title)
5362
require.NotNil(t, writeTool.Annotations.ReadOnlyHint)
5463
require.NotNil(t, writeTool.Annotations.DestructiveHint)
64+
require.NotNil(t, writeTool.Annotations.IdempotentHint)
65+
require.NotNil(t, writeTool.Annotations.OpenWorldHint)
5566
require.False(t, *writeTool.Annotations.ReadOnlyHint)
56-
require.True(t, *writeTool.Annotations.DestructiveHint)
67+
require.False(t, *writeTool.Annotations.DestructiveHint)
68+
require.True(t, *writeTool.Annotations.IdempotentHint)
69+
require.True(t, *writeTool.Annotations.OpenWorldHint)
5770
}

pkg/mcp/builders/pulsar/annotation_compliance_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,23 @@ func TestPulsarToolAnnotationCompliance(t *testing.T) {
4646
require.NotEmpty(t, tool.Annotations.Title, tool.Name)
4747
require.NotNil(t, tool.Annotations.ReadOnlyHint, tool.Name)
4848
require.NotNil(t, tool.Annotations.DestructiveHint, tool.Name)
49+
require.NotNil(t, tool.Annotations.IdempotentHint, tool.Name)
50+
require.NotNil(t, tool.Annotations.OpenWorldHint, tool.Name)
4951
require.LessOrEqual(t, len(tool.Name), 64, tool.Name)
5052

5153
isRead := strings.HasSuffix(tool.Name, "_read") || strings.HasPrefix(tool.Name, "pulsar_admin_namespace_policy_get") || tool.Name == "pulsar_admin_status" || tool.Name == "pulsar_admin_broker_stats" || tool.Name == "pulsar_admin_functions_worker"
5254
isWrite := strings.HasSuffix(tool.Name, "_write") || strings.Contains(tool.Name, "_set") || strings.Contains(tool.Name, "_remove") || strings.Contains(tool.Name, "produce") || strings.Contains(tool.Name, "consume")
5355
if isRead {
5456
require.True(t, *tool.Annotations.ReadOnlyHint, tool.Name)
5557
require.False(t, *tool.Annotations.DestructiveHint, tool.Name)
58+
require.True(t, *tool.Annotations.IdempotentHint, tool.Name)
59+
require.True(t, *tool.Annotations.OpenWorldHint, tool.Name)
5660
}
5761
if isWrite {
5862
require.False(t, *tool.Annotations.ReadOnlyHint, tool.Name)
5963
require.True(t, *tool.Annotations.DestructiveHint, tool.Name)
64+
require.False(t, *tool.Annotations.IdempotentHint, tool.Name)
65+
require.True(t, *tool.Annotations.OpenWorldHint, tool.Name)
6066
}
6167
assertOperationEnumMode(t, tool.Name, tool.InputSchema.Properties["operation"])
6268
}

0 commit comments

Comments
 (0)