Skip to content

Commit ec0350d

Browse files
authored
feat: add Tool.Title, OpenWorldHint=true, and OutputSchema for all tools (#45)
Closes #44 ## Changes ### Tool.Title - Add `pkg/tools/titles.go` with default human-readable titles for all 9 tools and a three-level priority chain (default → toolkit → per-registration) - Add `WithTitles(map[ToolName]string)` toolkit-level option - Add `WithTitle(string)` per-registration ToolOption - Wire `Title` field in all 9 tool registration calls ### OpenWorldHint - Change `OpenWorldHint` from `false` to `true` for all 9 tools; S3 tools interact with external object storage and are open-world by definition - Update annotations_test.go to assert `true` ### OutputSchema - Add `pkg/tools/output_schemas.go` with JSON Schema 2020-12 objects for all 9 tools, matching actual response struct fields in types.go - Schemas declare `type` + `properties` only (no `required` / no `additionalProperties: false`) to avoid runtime validation failures on optional or implementation-specific fields - Add `WithOutputSchemas(map[ToolName]any)` toolkit-level option - Add `WithOutputSchema(any)` per-registration ToolOption - Wire `OutputSchema` field in all 9 tool registration calls ### toolConfig / Toolkit structs - Add `title *string` and `outputSchema any` to `toolConfig` - Add `titles map[ToolName]string` and `outputSchemas map[ToolName]any` to `Toolkit`, initialised in `NewToolkit` ### Gosec fixes (pre-existing issues) - `pkg/client/config.go`: add `#nosec G117` to `SessionToken` field - `pkg/multiserver/config.go`: add `#nosec G117` to `SessionToken` field; update `#nosec G304` → `#nosec G304 G703` on both `os.ReadFile` calls - `pkg/client/client.go`: add `#nosec G118` to `context.WithTimeout` return (cancel func is intentionally returned to callers, not a leak) ### Documentation - `docs/reference/tools-api.md`: align all response examples with actual output schemas (fix `body`→`content`, `encoding`→`is_base64`, `key_count`→`count`, `default`→`default_connection`, add missing fields) - `docs/library/quickstart.md`: add "Tool Metadata Customization" section documenting `WithTitles`, `WithTitle`, `WithDescriptions`, `WithOutputSchemas`, and `WithOutputSchema` All checks pass: `make verify` (lint, test, coverage ≥80%, security, deadcode, build-check)
1 parent de65e76 commit ec0350d

22 files changed

Lines changed: 645 additions & 66 deletions

docs/library/quickstart.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,47 @@ cfg := &client.Config{
9696
s3Client, err := client.New(ctx, cfg)
9797
```
9898

99+
## Tool Metadata Customization
100+
101+
Override titles, descriptions, annotations, icons, and output schemas at the toolkit level or per registration.
102+
103+
### Toolkit-level overrides
104+
105+
```go
106+
toolkit := tools.NewToolkit(s3Client,
107+
// Override human-readable titles shown in MCP clients
108+
tools.WithTitles(map[tools.ToolName]string{
109+
tools.ToolListBuckets: "Browse Buckets",
110+
tools.ToolGetObject: "Fetch Object",
111+
}),
112+
113+
// Override descriptions
114+
tools.WithDescriptions(map[tools.ToolName]string{
115+
tools.ToolListBuckets: "Show all buckets available to this service account.",
116+
}),
117+
118+
// Override output schemas (JSON Schema 2020-12 as map[string]any)
119+
tools.WithOutputSchemas(map[tools.ToolName]any{
120+
tools.ToolListBuckets: map[string]any{
121+
"type": "object",
122+
"properties": map[string]any{
123+
"buckets": map[string]any{"type": "array"},
124+
"count": map[string]any{"type": "integer"},
125+
},
126+
},
127+
}),
128+
)
129+
```
130+
131+
### Per-registration overrides
132+
133+
```go
134+
toolkit.RegisterWith(mcpServer,
135+
tools.ToolListBuckets, tools.WithTitle("Browse Buckets"),
136+
tools.ToolGetObject, tools.WithOutputSchema(customSchema),
137+
)
138+
```
139+
99140
## Multiple Connections
100141

101142
```go

docs/reference/tools-api.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ List objects in a bucket with optional filtering.
4747

4848
```json
4949
{
50+
"bucket": "my-bucket",
51+
"prefix": "path/to/",
5052
"objects": [
5153
{
5254
"key": "path/to/file.txt",
@@ -57,9 +59,9 @@ List objects in a bucket with optional filtering.
5759
}
5860
],
5961
"common_prefixes": ["path/to/folder/"],
62+
"count": 1,
6063
"is_truncated": false,
61-
"next_continuation_token": null,
62-
"key_count": 1
64+
"next_continuation_token": null
6365
}
6466
```
6567

@@ -81,13 +83,15 @@ Retrieve object content.
8183

8284
```json
8385
{
86+
"bucket": "my-bucket",
8487
"key": "path/to/file.txt",
8588
"content_type": "text/plain",
8689
"size": 1024,
8790
"last_modified": "2024-01-15T10:30:00Z",
8891
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
89-
"body": "File content here...",
90-
"encoding": "text",
92+
"content": "File content here...",
93+
"is_base64": false,
94+
"truncated": false,
9195
"metadata": {
9296
"custom-key": "custom-value"
9397
}
@@ -96,8 +100,8 @@ Retrieve object content.
96100

97101
### Notes
98102

99-
- Text content is returned as-is
100-
- Binary content is returned as base64 with `encoding: "base64"`
103+
- Text content is returned as-is in `content`
104+
- Binary content is returned as base64 in `content` with `is_base64: true`
101105
- Objects larger than `MCP_S3_MAX_GET_SIZE` are rejected
102106

103107
---
@@ -118,6 +122,7 @@ Get object metadata without downloading content (HEAD request).
118122

119123
```json
120124
{
125+
"bucket": "my-bucket",
121126
"key": "path/to/file.txt",
122127
"content_type": "text/plain",
123128
"content_length": 1024,
@@ -154,7 +159,8 @@ Upload an object to S3.
154159
"bucket": "my-bucket",
155160
"key": "path/to/file.txt",
156161
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
157-
"size": 1024
162+
"size": 1024,
163+
"version_id": "abc123"
158164
}
159165
```
160166

@@ -216,7 +222,9 @@ Copy an object within or between buckets.
216222
"source_key": "path/to/source.txt",
217223
"dest_bucket": "dest-bucket",
218224
"dest_key": "path/to/dest.txt",
219-
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\""
225+
"etag": "\"d41d8cd98f00b204e9800998ecf8427e\"",
226+
"last_modified": "2024-01-15T10:30:00Z",
227+
"version_id": "abc123"
220228
}
221229
```
222230

@@ -244,8 +252,11 @@ Generate a presigned URL for direct access.
244252

245253
```json
246254
{
255+
"bucket": "my-bucket",
256+
"key": "path/to/file.txt",
247257
"url": "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=...",
248258
"method": "GET",
259+
"expires_in_seconds": 3600,
249260
"expires_at": "2024-01-15T11:30:00Z"
250261
}
251262
```
@@ -274,7 +285,8 @@ None.
274285
"endpoint": "http://localhost:8333"
275286
}
276287
],
277-
"default": "default"
288+
"default_connection": "default",
289+
"count": 2
278290
}
279291
```
280292

pkg/client/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ func (c *Client) contextWithTimeout(ctx context.Context) (context.Context, conte
480480
}
481481
}
482482

483-
return context.WithTimeout(ctx, c.config.Timeout)
483+
return context.WithTimeout(ctx, c.config.Timeout) //#nosec G118 -- cancel func is returned to caller
484484
}
485485

486486
// Close closes the S3 client and releases resources.

pkg/client/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type Config struct {
2929
SecretAccessKey string
3030

3131
// SessionToken is an optional session token for temporary credentials.
32-
SessionToken string
32+
SessionToken string //#nosec G117 -- field name is an AWS credential, not a secret exposure
3333

3434
// Profile is an optional AWS profile name to use from shared credentials/config.
3535
Profile string

pkg/multiserver/config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ type ConnectionConfig struct {
2828
SecretAccessKey string `json:"secret_access_key,omitempty" yaml:"secret_access_key,omitempty"`
2929

3030
// SessionToken is an optional session token.
31-
SessionToken string `json:"session_token,omitempty" yaml:"session_token,omitempty"`
31+
SessionToken string `json:"session_token,omitempty" yaml:"session_token,omitempty"` //#nosec G117 -- AWS credential field
3232

3333
// Profile is an optional AWS profile name.
3434
Profile string `json:"profile,omitempty" yaml:"profile,omitempty"`
@@ -94,7 +94,7 @@ func FromEnvJSON() (*MultiConfig, error) {
9494

9595
// FromYAMLFile loads multi-connection configuration from a YAML file.
9696
func FromYAMLFile(path string) (*MultiConfig, error) {
97-
data, err := os.ReadFile(path) //#nosec G304 -- Path is intentionally user-provided config file
97+
data, err := os.ReadFile(path) //#nosec G304 G703 -- Path is intentionally user-provided config file
9898
if err != nil {
9999
return nil, err
100100
}
@@ -109,7 +109,7 @@ func FromYAMLFile(path string) (*MultiConfig, error) {
109109

110110
// FromJSONFile loads multi-connection configuration from a JSON file.
111111
func FromJSONFile(path string) (*MultiConfig, error) {
112-
data, err := os.ReadFile(path) //#nosec G304 -- Path is intentionally user-provided config file
112+
data, err := os.ReadFile(path) //#nosec G304 G703 -- Path is intentionally user-provided config file
113113
if err != nil {
114114
return nil, err
115115
}

pkg/tools/annotations.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,45 +18,45 @@ var defaultAnnotations = map[ToolName]*mcp.ToolAnnotations{
1818
ToolListBuckets: {
1919
ReadOnlyHint: true,
2020
IdempotentHint: true,
21-
OpenWorldHint: boolPtr(false),
21+
OpenWorldHint: boolPtr(true),
2222
},
2323
ToolListConnections: {
2424
ReadOnlyHint: true,
2525
IdempotentHint: true,
26-
OpenWorldHint: boolPtr(false),
26+
OpenWorldHint: boolPtr(true),
2727
},
2828
ToolListObjects: {
2929
ReadOnlyHint: true,
3030
IdempotentHint: true,
31-
OpenWorldHint: boolPtr(false),
31+
OpenWorldHint: boolPtr(true),
3232
},
3333
ToolGetObject: {
3434
ReadOnlyHint: true,
3535
IdempotentHint: true,
36-
OpenWorldHint: boolPtr(false),
36+
OpenWorldHint: boolPtr(true),
3737
},
3838
ToolGetObjectMetadata: {
3939
ReadOnlyHint: true,
4040
IdempotentHint: true,
41-
OpenWorldHint: boolPtr(false),
41+
OpenWorldHint: boolPtr(true),
4242
},
4343
ToolPresignURL: {
4444
ReadOnlyHint: true,
45-
OpenWorldHint: boolPtr(false),
45+
OpenWorldHint: boolPtr(true),
4646
},
4747
ToolPutObject: {
4848
DestructiveHint: boolPtr(false),
4949
IdempotentHint: true,
50-
OpenWorldHint: boolPtr(false),
50+
OpenWorldHint: boolPtr(true),
5151
},
5252
ToolCopyObject: {
5353
DestructiveHint: boolPtr(false),
5454
IdempotentHint: true,
55-
OpenWorldHint: boolPtr(false),
55+
OpenWorldHint: boolPtr(true),
5656
},
5757
ToolDeleteObject: {
5858
IdempotentHint: true,
59-
OpenWorldHint: boolPtr(false),
59+
OpenWorldHint: boolPtr(true),
6060
},
6161
}
6262

pkg/tools/annotations_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,14 @@ func TestDefaultAnnotations(t *testing.T) {
8282
}
8383
})
8484

85-
t.Run("all tools are closed-world", func(t *testing.T) {
85+
t.Run("all tools are open-world", func(t *testing.T) {
8686
for _, name := range AllTools() {
8787
ann := DefaultAnnotations(name)
8888
if ann == nil {
8989
t.Fatalf("tool %s has no default annotations", name)
9090
}
91-
if ann.OpenWorldHint == nil || *ann.OpenWorldHint {
92-
t.Errorf("tool %s should have OpenWorldHint=false", name)
91+
if ann.OpenWorldHint == nil || !*ann.OpenWorldHint {
92+
t.Errorf("tool %s should have OpenWorldHint=true", name)
9393
}
9494
}
9595
})

pkg/tools/connections.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ func (t *Toolkit) registerListConnectionsTool(server *mcp.Server, cfg *toolConfi
3333
wrappedHandler := t.wrapHandler(ToolListConnections, baseHandler, cfg)
3434

3535
mcp.AddTool(server, &mcp.Tool{
36-
Name: t.toolName(ToolListConnections),
37-
Description: t.getDescription(ToolListConnections, cfg),
38-
Annotations: t.getAnnotations(ToolListConnections, cfg),
39-
Icons: t.getIcons(ToolListConnections, cfg),
36+
Name: t.toolName(ToolListConnections),
37+
Title: t.getTitle(ToolListConnections, cfg),
38+
Description: t.getDescription(ToolListConnections, cfg),
39+
Annotations: t.getAnnotations(ToolListConnections, cfg),
40+
Icons: t.getIcons(ToolListConnections, cfg),
41+
OutputSchema: t.getOutputSchema(ToolListConnections, cfg),
4042
}, func(ctx context.Context, req *mcp.CallToolRequest, input ListConnectionsInput) (*mcp.CallToolResult, *ListConnectionsResult, error) {
4143
result, out, err := wrappedHandler(ctx, req, input)
4244
if typed, ok := out.(*ListConnectionsResult); ok {

pkg/tools/copy_object.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@ func (t *Toolkit) registerCopyObjectTool(server *mcp.Server, cfg *toolConfig) {
3232
wrappedHandler := t.wrapHandler(ToolCopyObject, baseHandler, cfg)
3333

3434
mcp.AddTool(server, &mcp.Tool{
35-
Name: t.toolName(ToolCopyObject),
36-
Description: t.getDescription(ToolCopyObject, cfg),
37-
Annotations: t.getAnnotations(ToolCopyObject, cfg),
38-
Icons: t.getIcons(ToolCopyObject, cfg),
35+
Name: t.toolName(ToolCopyObject),
36+
Title: t.getTitle(ToolCopyObject, cfg),
37+
Description: t.getDescription(ToolCopyObject, cfg),
38+
Annotations: t.getAnnotations(ToolCopyObject, cfg),
39+
Icons: t.getIcons(ToolCopyObject, cfg),
40+
OutputSchema: t.getOutputSchema(ToolCopyObject, cfg),
3941
}, func(ctx context.Context, req *mcp.CallToolRequest, input CopyObjectInput) (*mcp.CallToolResult, *CopyObjectResult, error) {
4042
result, out, err := wrappedHandler(ctx, req, input)
4143
if typed, ok := out.(*CopyObjectResult); ok {

pkg/tools/delete_object.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ func (t *Toolkit) registerDeleteObjectTool(server *mcp.Server, cfg *toolConfig)
2626
wrappedHandler := t.wrapHandler(ToolDeleteObject, baseHandler, cfg)
2727

2828
mcp.AddTool(server, &mcp.Tool{
29-
Name: t.toolName(ToolDeleteObject),
30-
Description: t.getDescription(ToolDeleteObject, cfg),
31-
Annotations: t.getAnnotations(ToolDeleteObject, cfg),
32-
Icons: t.getIcons(ToolDeleteObject, cfg),
29+
Name: t.toolName(ToolDeleteObject),
30+
Title: t.getTitle(ToolDeleteObject, cfg),
31+
Description: t.getDescription(ToolDeleteObject, cfg),
32+
Annotations: t.getAnnotations(ToolDeleteObject, cfg),
33+
Icons: t.getIcons(ToolDeleteObject, cfg),
34+
OutputSchema: t.getOutputSchema(ToolDeleteObject, cfg),
3335
}, func(ctx context.Context, req *mcp.CallToolRequest, input DeleteObjectInput) (*mcp.CallToolResult, *DeleteObjectResult, error) {
3436
result, out, err := wrappedHandler(ctx, req, input)
3537
if typed, ok := out.(*DeleteObjectResult); ok {

0 commit comments

Comments
 (0)