Skip to content

Commit 44c314b

Browse files
authored
Add tool annotations support (yjacquin#96)
* Add tool annotations support * use snake case for annotation keys * fix linter
1 parent 7c29336 commit 44c314b

File tree

8 files changed

+281
-1
lines changed

8 files changed

+281
-1
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
### Added
10+
- Tool annotations support for providing hints about tool behavior (readOnlyHint, destructiveHint, etc.)
11+
812
## [1.5.0] - 2025-06-01
913
### Added
1014
- Handle filtering tools and resources [#85 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/85)

docs/tools.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Tools are a core concept in the Model Context Protocol (MCP). They allow you to
1313
- [Default Values](#default-values)
1414
- [Calling Tools From Another Tool](#calling-tools-from-another-tool)
1515
- [Advanced Tool Features](#advanced-tool-features)
16+
- [Tool Annotations](#tool-annotations)
1617
- [Tool Hidden Arguments](#tool-hidden-arguments)
1718
- [Tool Categories](#tool-categories)
1819
- [Tool Metadata](#tool-metadata)
@@ -277,6 +278,67 @@ end
277278

278279
## Advanced Tool Features
279280

281+
### Tool Annotations
282+
283+
Tool annotations provide additional metadata about a tool's behavior, helping clients understand how to present and manage tools. These annotations are hints that describe the nature and impact of a tool.
284+
285+
```ruby
286+
class WebSearchTool < FastMcp::Tool
287+
description 'Search the web for information'
288+
289+
annotations(
290+
title: 'Web Search', # Human-readable title for the tool
291+
read_only_hint: true, # Indicates the tool doesn't modify its environment
292+
open_world_hint: true # The tool interacts with external entities
293+
)
294+
295+
arguments do
296+
required(:query).filled(:string).description('Search query')
297+
end
298+
299+
def call(query:)
300+
"Searching for: #{query}"
301+
end
302+
end
303+
```
304+
305+
Available annotations:
306+
307+
| Annotation | Type | Default | Description |
308+
|------------|------|---------|-------------|
309+
| `title` | string | - | A human-readable title for the tool, useful for UI display |
310+
| `read_only_hint` | boolean | false | If true, indicates the tool does not modify its environment |
311+
| `destructive_hint` | boolean | true | If true, the tool may perform destructive updates (only meaningful when `read_only_hint` is false) |
312+
| `idempotent_hint` | boolean | false | If true, calling the tool repeatedly with the same arguments has no additional effect |
313+
| `open_world_hint` | boolean | true | If true, the tool may interact with an "open world" of external entities |
314+
315+
Example with all annotations:
316+
317+
```ruby
318+
class DeleteFileTool < FastMcp::Tool
319+
description 'Delete a file from the filesystem'
320+
321+
annotations(
322+
title: 'Delete File',
323+
read_only_hint: false, # This tool modifies the filesystem
324+
destructive_hint: true, # Deleting files is destructive
325+
idempotent_hint: true, # Deleting the same file twice has no additional effect
326+
open_world_hint: false # Only interacts with the local filesystem
327+
)
328+
329+
arguments do
330+
required(:path).filled(:string).description('File path to delete')
331+
end
332+
333+
def call(path:)
334+
File.delete(path) if File.exist?(path)
335+
"File deleted: #{path}"
336+
end
337+
end
338+
```
339+
340+
**Important**: Annotations are hints and not guaranteed to provide a faithful description of tool behavior. Clients should never make security-critical decisions based solely on annotations.
341+
280342
### Tool hidden arguments
281343
If need be, we can register arguments that won't show up in the tools/list call but can still be used in the tool when provided.
282344
This might be useful when calling from another tool, or when the client is made aware of this argument from the context.

examples/tool_examples.rb

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,88 @@ def call(args)
201201
email: 'jane@example.com',
202202
age: 25
203203
)}"
204+
puts
205+
206+
# Example 7: Tools with annotations
207+
puts 'Example 7: Tools with annotations'
208+
209+
class WebSearchTool < FastMcp::Tool
210+
description 'Search the web for information'
211+
212+
annotations(
213+
title: 'Web Search',
214+
read_only_hint: true,
215+
open_world_hint: true
216+
)
217+
218+
arguments do
219+
required(:query).filled(:string).description('Search query')
220+
optional(:max_results).filled(:integer, gteq?: 1, lteq?: 50).description('Maximum number of results')
221+
end
222+
223+
def call(args)
224+
max_results = args[:max_results] || 10
225+
"Searching for '#{args[:query]}' (returning up to #{max_results} results)..."
226+
end
227+
end
228+
229+
class DeleteFileTool < FastMcp::Tool
230+
description 'Delete a file from the filesystem'
231+
232+
annotations(
233+
title: 'Delete File',
234+
read_only_hint: false,
235+
destructive_hint: true,
236+
idempotent_hint: true,
237+
open_world_hint: false
238+
)
239+
240+
arguments do
241+
required(:path).filled(:string).description('File path to delete')
242+
end
243+
244+
def call(args)
245+
"Would delete file at: #{args[:path]}"
246+
end
247+
end
248+
249+
class CreateRecordTool < FastMcp::Tool
250+
description 'Create a new record in the database'
251+
252+
annotations(
253+
title: 'Create Database Record',
254+
read_only_hint: false,
255+
destructive_hint: false,
256+
idempotent_hint: false,
257+
open_world_hint: false
258+
)
259+
260+
arguments do
261+
required(:table).filled(:string).description('Database table name')
262+
required(:data).hash.description('Record data')
263+
end
264+
265+
def call(args)
266+
"Creating record in #{args[:table]} with data: #{args[:data].inspect}"
267+
end
268+
end
269+
270+
# Demonstrate the web search tool
271+
web_search = WebSearchTool.new
272+
puts "Tool: #{web_search.class.name}"
273+
puts "Annotations: #{web_search.class.annotations.inspect}"
274+
puts "Call result: #{web_search.call(query: 'Ruby programming')}"
275+
puts
276+
277+
# Demonstrate the delete file tool
278+
delete_file = DeleteFileTool.new
279+
puts "Tool: #{delete_file.class.name}"
280+
puts "Annotations: #{delete_file.class.annotations.inspect}"
281+
puts "Call result: #{delete_file.call(path: '/tmp/test.txt')}"
282+
puts
283+
284+
# Demonstrate the create record tool
285+
create_record = CreateRecordTool.new
286+
puts "Tool: #{create_record.class.name}"
287+
puts "Annotations: #{create_record.class.annotations.inspect}"
288+
puts "Call result: #{create_record.call(table: 'users', data: { name: 'John', email: 'john@example.com' })}"

lib/generators/fast_mcp/install/templates/sample_tool.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
class SampleTool < ApplicationTool
44
description 'Greet a user'
55

6+
# Optional: Add annotations to provide hints about the tool's behavior
7+
# annotations(
8+
# title: 'User Greeting',
9+
# read_only_hint: true, # This tool only reads data
10+
# open_world_hint: false # This tool only accesses the local database
11+
# )
12+
613
arguments do
714
required(:id).filled(:integer).description('ID of the user to greet')
815
optional(:prefix).filled(:string).description('Prefix to add to the greeting')

lib/mcp/server.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,25 @@ def handle_initialized_notification
294294
# Handle tools/list request
295295
def handle_tools_list(id)
296296
tools_list = @tools.values.map do |tool|
297-
{
297+
tool_info = {
298298
name: tool.tool_name,
299299
description: tool.description || '',
300300
inputSchema: tool.input_schema_to_json || { type: 'object', properties: {}, required: [] }
301301
}
302+
303+
# Add annotations if they exist
304+
annotations = tool.annotations
305+
unless annotations.empty?
306+
# Convert snake_case keys to camelCase for MCP protocol
307+
camel_case_annotations = {}
308+
annotations.each do |key, value|
309+
camel_key = key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }.to_sym
310+
camel_case_annotations[camel_key] = value
311+
end
312+
tool_info[:annotations] = camel_case_annotations
313+
end
314+
315+
tool_info
302316
end
303317

304318
send_result({ tools: tools_list }, id)

lib/mcp/tool.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ def description(description = nil)
138138
@description = description
139139
end
140140

141+
def annotations(annotations_hash = nil)
142+
return @annotations || {} if annotations_hash.nil?
143+
144+
@annotations = annotations_hash
145+
end
146+
141147
def authorize(&block)
142148
@authorization_blocks ||= []
143149
@authorization_blocks.push block

spec/mcp/server_spec.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,66 @@ def call(user:)
156156

157157
server.handle_request(request)
158158
end
159+
160+
context 'with tool annotations' do
161+
let(:annotated_tool_class) do
162+
Class.new(FastMcp::Tool) do
163+
def self.name
164+
'annotated-tool'
165+
end
166+
167+
def self.description
168+
'A tool with annotations'
169+
end
170+
171+
annotations(
172+
title: 'Web Search Tool',
173+
read_only_hint: true,
174+
open_world_hint: true
175+
)
176+
177+
def call(**_args)
178+
'Searching...'
179+
end
180+
end
181+
end
182+
183+
before do
184+
server.register_tool(annotated_tool_class)
185+
end
186+
187+
it 'includes annotations in the tools list' do
188+
request = { jsonrpc: '2.0', method: 'tools/list', id: 1 }.to_json
189+
190+
expect(server).to receive(:send_result) do |result, id|
191+
expect(id).to eq(1)
192+
193+
annotated_tool = result[:tools].find { |t| t[:name] == 'annotated-tool' }
194+
expect(annotated_tool[:annotations]).to eq({
195+
title: 'Web Search Tool',
196+
readOnlyHint: true,
197+
openWorldHint: true
198+
})
199+
end
200+
201+
server.handle_request(request)
202+
end
203+
end
204+
205+
context 'with tool without annotations' do
206+
it 'does not include annotations field' do
207+
request = { jsonrpc: '2.0', method: 'tools/list', id: 1 }.to_json
208+
209+
expect(server).to receive(:send_result) do |result, id|
210+
expect(id).to eq(1)
211+
212+
test_tool = result[:tools].find { |t| t[:name] == 'test-tool' }
213+
expect(test_tool).not_to have_key(:annotations)
214+
end
215+
216+
server.handle_request(request)
217+
end
218+
end
159219
end
160220

161221
context 'with a tools/call request' do

spec/mcp/tool_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,4 +643,46 @@ def call(**_args)
643643
expect(test_class.metadata(:undefined_key)).to be_nil
644644
end
645645
end
646+
647+
describe '.annotations' do
648+
it 'sets and returns annotations hash' do
649+
test_class = Class.new(described_class)
650+
annotations = {
651+
title: 'Web Search',
652+
read_only_hint: true,
653+
open_world_hint: true
654+
}
655+
test_class.annotations(annotations)
656+
657+
expect(test_class.annotations).to eq(annotations)
658+
end
659+
660+
it 'returns empty hash when no annotations are set' do
661+
test_class = Class.new(described_class)
662+
663+
expect(test_class.annotations).to eq({})
664+
end
665+
666+
it 'returns the current annotations when called with nil' do
667+
test_class = Class.new(described_class)
668+
annotations = { title: 'Test Tool' }
669+
test_class.annotations(annotations)
670+
671+
expect(test_class.annotations(nil)).to eq(annotations)
672+
end
673+
674+
it 'supports all MCP annotation fields' do
675+
test_class = Class.new(described_class)
676+
annotations = {
677+
title: 'Delete File',
678+
read_only_hint: false,
679+
destructive_hint: true,
680+
idempotent_hint: true,
681+
open_world_hint: false
682+
}
683+
test_class.annotations(annotations)
684+
685+
expect(test_class.annotations).to eq(annotations)
686+
end
687+
end
646688
end

0 commit comments

Comments
 (0)