claude_agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/commands/spec/complete.md +105 -0
- data/.claude/commands/spec/update.md +95 -0
- data/.claude/rules/conventions.md +622 -0
- data/.claude/rules/git.md +86 -0
- data/.claude/rules/pull-requests.md +31 -0
- data/.claude/rules/releases.md +177 -0
- data/.claude/rules/testing.md +267 -0
- data/.claude/settings.json +49 -0
- data/CHANGELOG.md +13 -0
- data/CLAUDE.md +94 -0
- data/LICENSE.txt +21 -0
- data/README.md +679 -0
- data/Rakefile +63 -0
- data/SPEC.md +558 -0
- data/lib/claude_agent/abort_controller.rb +113 -0
- data/lib/claude_agent/client.rb +298 -0
- data/lib/claude_agent/content_blocks.rb +163 -0
- data/lib/claude_agent/control_protocol.rb +717 -0
- data/lib/claude_agent/errors.rb +103 -0
- data/lib/claude_agent/hooks.rb +228 -0
- data/lib/claude_agent/mcp/server.rb +166 -0
- data/lib/claude_agent/mcp/tool.rb +137 -0
- data/lib/claude_agent/message_parser.rb +262 -0
- data/lib/claude_agent/messages.rb +421 -0
- data/lib/claude_agent/options.rb +264 -0
- data/lib/claude_agent/permissions.rb +164 -0
- data/lib/claude_agent/query.rb +90 -0
- data/lib/claude_agent/sandbox_settings.rb +139 -0
- data/lib/claude_agent/spawn.rb +235 -0
- data/lib/claude_agent/transport/base.rb +61 -0
- data/lib/claude_agent/transport/subprocess.rb +432 -0
- data/lib/claude_agent/types.rb +193 -0
- data/lib/claude_agent/version.rb +5 -0
- data/lib/claude_agent.rb +28 -0
- data/sig/claude_agent.rbs +912 -0
- data/sig/manifest.yaml +5 -0
- metadata +97 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
# Base error class for all ClaudeAgent errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the Claude Code CLI cannot be found
|
|
8
|
+
class CLINotFoundError < Error
|
|
9
|
+
def initialize(message = "Claude Code CLI not found. Please install it first.")
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Raised when the CLI version is below minimum required
|
|
15
|
+
class CLIVersionError < Error
|
|
16
|
+
MINIMUM_VERSION = "2.0.0"
|
|
17
|
+
|
|
18
|
+
def initialize(found_version = nil)
|
|
19
|
+
message = if found_version
|
|
20
|
+
"Claude Code CLI version #{found_version} is below minimum required version #{MINIMUM_VERSION}"
|
|
21
|
+
else
|
|
22
|
+
"Could not determine Claude Code CLI version. Minimum required: #{MINIMUM_VERSION}"
|
|
23
|
+
end
|
|
24
|
+
super(message)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when connection to CLI fails
|
|
29
|
+
class CLIConnectionError < Error
|
|
30
|
+
def initialize(message = "Failed to connect to Claude Code CLI")
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raised when the CLI process exits with an error
|
|
36
|
+
class ProcessError < Error
|
|
37
|
+
attr_reader :exit_code, :stderr
|
|
38
|
+
|
|
39
|
+
def initialize(message = "CLI process failed", exit_code: nil, stderr: nil)
|
|
40
|
+
@exit_code = exit_code
|
|
41
|
+
@stderr = stderr
|
|
42
|
+
full_message = message
|
|
43
|
+
full_message += " (exit code: #{exit_code})" if exit_code
|
|
44
|
+
full_message += "\nStderr: #{stderr}" if stderr && !stderr.empty?
|
|
45
|
+
super(full_message)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Raised when JSON parsing fails
|
|
50
|
+
class JSONDecodeError < Error
|
|
51
|
+
attr_reader :raw_content
|
|
52
|
+
|
|
53
|
+
def initialize(message = "Failed to decode JSON", raw_content: nil)
|
|
54
|
+
@raw_content = raw_content
|
|
55
|
+
full_message = message
|
|
56
|
+
full_message += "\nContent: #{raw_content[0..200]}..." if raw_content
|
|
57
|
+
super(full_message)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Raised when message parsing fails
|
|
62
|
+
class MessageParseError < Error
|
|
63
|
+
attr_reader :raw_message
|
|
64
|
+
|
|
65
|
+
def initialize(message = "Failed to parse message", raw_message: nil)
|
|
66
|
+
@raw_message = raw_message
|
|
67
|
+
full_message = message
|
|
68
|
+
full_message += "\nRaw message: #{raw_message.inspect[0..200]}" if raw_message
|
|
69
|
+
super(full_message)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Raised when a control protocol request times out
|
|
74
|
+
class TimeoutError < Error
|
|
75
|
+
attr_reader :request_id, :timeout_seconds
|
|
76
|
+
|
|
77
|
+
def initialize(message = "Request timed out", request_id: nil, timeout_seconds: nil)
|
|
78
|
+
@request_id = request_id
|
|
79
|
+
@timeout_seconds = timeout_seconds
|
|
80
|
+
full_message = message
|
|
81
|
+
full_message += " (request_id: #{request_id})" if request_id
|
|
82
|
+
full_message += " after #{timeout_seconds}s" if timeout_seconds
|
|
83
|
+
super(full_message)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Raised when an invalid option is provided
|
|
88
|
+
class ConfigurationError < Error; end
|
|
89
|
+
|
|
90
|
+
# Raised when an operation is aborted/cancelled (TypeScript SDK parity)
|
|
91
|
+
#
|
|
92
|
+
# This error is raised when an operation is explicitly cancelled,
|
|
93
|
+
# such as through a user interrupt or abort signal.
|
|
94
|
+
#
|
|
95
|
+
# @example
|
|
96
|
+
# raise ClaudeAgent::AbortError, "Operation cancelled by user"
|
|
97
|
+
#
|
|
98
|
+
class AbortError < Error
|
|
99
|
+
def initialize(message = "Operation was aborted")
|
|
100
|
+
super
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
# Hook events that can be intercepted (TypeScript SDK parity)
|
|
5
|
+
HOOK_EVENTS = %w[
|
|
6
|
+
PreToolUse
|
|
7
|
+
PostToolUse
|
|
8
|
+
PostToolUseFailure
|
|
9
|
+
Notification
|
|
10
|
+
UserPromptSubmit
|
|
11
|
+
SessionStart
|
|
12
|
+
SessionEnd
|
|
13
|
+
Stop
|
|
14
|
+
SubagentStart
|
|
15
|
+
SubagentStop
|
|
16
|
+
PreCompact
|
|
17
|
+
PermissionRequest
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Matcher configuration for hooks
|
|
21
|
+
#
|
|
22
|
+
# @example Basic usage
|
|
23
|
+
# matcher = HookMatcher.new(
|
|
24
|
+
# matcher: "Bash|Write",
|
|
25
|
+
# callbacks: [->(input, context) { {continue_: true} }],
|
|
26
|
+
# timeout: 30
|
|
27
|
+
# )
|
|
28
|
+
#
|
|
29
|
+
HookMatcher = Data.define(:matcher, :callbacks, :timeout) do
|
|
30
|
+
def initialize(matcher:, callbacks:, timeout: nil)
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if this matcher matches a tool name
|
|
35
|
+
# @param tool_name [String] Tool name to check
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def matches?(tool_name)
|
|
38
|
+
case matcher
|
|
39
|
+
when String
|
|
40
|
+
if matcher.include?("|")
|
|
41
|
+
matcher.split("|").any? { |m| tool_name == m }
|
|
42
|
+
else
|
|
43
|
+
Regexp.new(matcher).match?(tool_name)
|
|
44
|
+
end
|
|
45
|
+
when Regexp
|
|
46
|
+
matcher.match?(tool_name)
|
|
47
|
+
else
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Context passed to hook callbacks
|
|
54
|
+
#
|
|
55
|
+
HookContext = Data.define(:tool_use_id) do
|
|
56
|
+
def initialize(tool_use_id: nil)
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Base class for hook input types (TypeScript SDK parity)
|
|
62
|
+
#
|
|
63
|
+
class BaseHookInput
|
|
64
|
+
attr_reader :hook_event_name, :session_id, :transcript_path, :cwd, :permission_mode
|
|
65
|
+
|
|
66
|
+
def initialize(hook_event_name:, session_id: nil, transcript_path: nil, cwd: nil, permission_mode: nil, **kwargs)
|
|
67
|
+
@hook_event_name = hook_event_name
|
|
68
|
+
@session_id = session_id
|
|
69
|
+
@transcript_path = transcript_path
|
|
70
|
+
@cwd = cwd
|
|
71
|
+
@permission_mode = permission_mode
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Input for PreToolUse hook
|
|
76
|
+
#
|
|
77
|
+
class PreToolUseInput < BaseHookInput
|
|
78
|
+
attr_reader :tool_name, :tool_input, :tool_use_id
|
|
79
|
+
|
|
80
|
+
def initialize(tool_name:, tool_input:, tool_use_id: nil, **kwargs)
|
|
81
|
+
super(hook_event_name: "PreToolUse", **kwargs)
|
|
82
|
+
@tool_name = tool_name
|
|
83
|
+
@tool_input = tool_input
|
|
84
|
+
@tool_use_id = tool_use_id
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Input for PostToolUse hook
|
|
89
|
+
#
|
|
90
|
+
class PostToolUseInput < BaseHookInput
|
|
91
|
+
attr_reader :tool_name, :tool_input, :tool_response, :tool_use_id
|
|
92
|
+
|
|
93
|
+
def initialize(tool_name:, tool_input:, tool_response:, tool_use_id: nil, **kwargs)
|
|
94
|
+
super(hook_event_name: "PostToolUse", **kwargs)
|
|
95
|
+
@tool_name = tool_name
|
|
96
|
+
@tool_input = tool_input
|
|
97
|
+
@tool_response = tool_response
|
|
98
|
+
@tool_use_id = tool_use_id
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Input for PostToolUseFailure hook (TypeScript SDK parity)
|
|
103
|
+
#
|
|
104
|
+
class PostToolUseFailureInput < BaseHookInput
|
|
105
|
+
attr_reader :tool_name, :tool_input, :tool_use_id, :error, :is_interrupt
|
|
106
|
+
|
|
107
|
+
def initialize(tool_name:, tool_input:, error:, tool_use_id: nil, is_interrupt: nil, **kwargs)
|
|
108
|
+
super(hook_event_name: "PostToolUseFailure", **kwargs)
|
|
109
|
+
@tool_name = tool_name
|
|
110
|
+
@tool_input = tool_input
|
|
111
|
+
@tool_use_id = tool_use_id
|
|
112
|
+
@error = error
|
|
113
|
+
@is_interrupt = is_interrupt
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Input for Notification hook (TypeScript SDK parity)
|
|
118
|
+
#
|
|
119
|
+
class NotificationInput < BaseHookInput
|
|
120
|
+
attr_reader :message, :title, :notification_type
|
|
121
|
+
|
|
122
|
+
def initialize(message:, title: nil, notification_type: nil, **kwargs)
|
|
123
|
+
super(hook_event_name: "Notification", **kwargs)
|
|
124
|
+
@message = message
|
|
125
|
+
@title = title
|
|
126
|
+
@notification_type = notification_type
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Input for UserPromptSubmit hook
|
|
131
|
+
#
|
|
132
|
+
class UserPromptSubmitInput < BaseHookInput
|
|
133
|
+
attr_reader :prompt
|
|
134
|
+
|
|
135
|
+
def initialize(prompt:, **kwargs)
|
|
136
|
+
super(hook_event_name: "UserPromptSubmit", **kwargs)
|
|
137
|
+
@prompt = prompt
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Input for SessionStart hook (TypeScript SDK parity)
|
|
142
|
+
#
|
|
143
|
+
class SessionStartInput < BaseHookInput
|
|
144
|
+
attr_reader :source, :agent_type
|
|
145
|
+
|
|
146
|
+
# @param source [String] One of: "startup", "resume", "clear", "compact"
|
|
147
|
+
# @param agent_type [String, nil] Type of agent if running in subagent context
|
|
148
|
+
def initialize(source:, agent_type: nil, **kwargs)
|
|
149
|
+
super(hook_event_name: "SessionStart", **kwargs)
|
|
150
|
+
@source = source
|
|
151
|
+
@agent_type = agent_type
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Input for SessionEnd hook (TypeScript SDK parity)
|
|
156
|
+
#
|
|
157
|
+
class SessionEndInput < BaseHookInput
|
|
158
|
+
attr_reader :reason
|
|
159
|
+
|
|
160
|
+
def initialize(reason:, **kwargs)
|
|
161
|
+
super(hook_event_name: "SessionEnd", **kwargs)
|
|
162
|
+
@reason = reason
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Input for Stop hook
|
|
167
|
+
#
|
|
168
|
+
class StopInput < BaseHookInput
|
|
169
|
+
attr_reader :stop_hook_active
|
|
170
|
+
|
|
171
|
+
def initialize(stop_hook_active: false, **kwargs)
|
|
172
|
+
super(hook_event_name: "Stop", **kwargs)
|
|
173
|
+
@stop_hook_active = stop_hook_active
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Input for SubagentStart hook (TypeScript SDK parity)
|
|
178
|
+
#
|
|
179
|
+
class SubagentStartInput < BaseHookInput
|
|
180
|
+
attr_reader :agent_id, :agent_type
|
|
181
|
+
|
|
182
|
+
def initialize(agent_id:, agent_type:, **kwargs)
|
|
183
|
+
super(hook_event_name: "SubagentStart", **kwargs)
|
|
184
|
+
@agent_id = agent_id
|
|
185
|
+
@agent_type = agent_type
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Input for SubagentStop hook
|
|
190
|
+
#
|
|
191
|
+
class SubagentStopInput < BaseHookInput
|
|
192
|
+
attr_reader :stop_hook_active, :agent_id, :agent_transcript_path
|
|
193
|
+
|
|
194
|
+
def initialize(stop_hook_active: false, agent_id: nil, agent_transcript_path: nil, **kwargs)
|
|
195
|
+
super(hook_event_name: "SubagentStop", **kwargs)
|
|
196
|
+
@stop_hook_active = stop_hook_active
|
|
197
|
+
@agent_id = agent_id
|
|
198
|
+
@agent_transcript_path = agent_transcript_path
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Input for PreCompact hook
|
|
203
|
+
#
|
|
204
|
+
class PreCompactInput < BaseHookInput
|
|
205
|
+
attr_reader :trigger, :custom_instructions
|
|
206
|
+
|
|
207
|
+
# @param trigger [String] One of: "manual", "auto"
|
|
208
|
+
# @param custom_instructions [String, nil] Custom instructions for compaction
|
|
209
|
+
def initialize(trigger:, custom_instructions: nil, **kwargs)
|
|
210
|
+
super(hook_event_name: "PreCompact", **kwargs)
|
|
211
|
+
@trigger = trigger
|
|
212
|
+
@custom_instructions = custom_instructions
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Input for PermissionRequest hook (TypeScript SDK parity)
|
|
217
|
+
#
|
|
218
|
+
class PermissionRequestInput < BaseHookInput
|
|
219
|
+
attr_reader :tool_name, :tool_input, :permission_suggestions
|
|
220
|
+
|
|
221
|
+
def initialize(tool_name:, tool_input:, permission_suggestions: nil, **kwargs)
|
|
222
|
+
super(hook_event_name: "PermissionRequest", **kwargs)
|
|
223
|
+
@tool_name = tool_name
|
|
224
|
+
@tool_input = tool_input
|
|
225
|
+
@permission_suggestions = permission_suggestions
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeAgent
|
|
6
|
+
module MCP
|
|
7
|
+
# In-process MCP server for hosting tools
|
|
8
|
+
#
|
|
9
|
+
# Unlike external MCP servers that run as subprocesses, SDK servers
|
|
10
|
+
# run in the same Ruby process, providing better performance and
|
|
11
|
+
# easier debugging.
|
|
12
|
+
#
|
|
13
|
+
# @example Create a server with tools
|
|
14
|
+
# add_tool = ClaudeAgent::MCP::Tool.new(
|
|
15
|
+
# name: "add",
|
|
16
|
+
# description: "Add two numbers",
|
|
17
|
+
# schema: {a: Float, b: Float}
|
|
18
|
+
# ) { |args| args["a"] + args["b"] }
|
|
19
|
+
#
|
|
20
|
+
# server = ClaudeAgent::MCP::Server.new(
|
|
21
|
+
# name: "calculator",
|
|
22
|
+
# tools: [add_tool]
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# @example Use with ClaudeAgent
|
|
26
|
+
# options = ClaudeAgent::Options.new(
|
|
27
|
+
# mcp_servers: {
|
|
28
|
+
# "calculator" => {type: "sdk", instance: server}
|
|
29
|
+
# }
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
class Server
|
|
33
|
+
attr_reader :name, :tools
|
|
34
|
+
|
|
35
|
+
# @param name [String] Server name
|
|
36
|
+
# @param tools [Array<Tool>] Tools to expose
|
|
37
|
+
def initialize(name:, tools: [])
|
|
38
|
+
@name = name.to_s
|
|
39
|
+
@tools = {}
|
|
40
|
+
tools.each { |tool| add_tool(tool) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Add a tool to the server
|
|
44
|
+
# @param tool [Tool] Tool to add
|
|
45
|
+
# @return [void]
|
|
46
|
+
def add_tool(tool)
|
|
47
|
+
@tools[tool.name] = tool
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Remove a tool from the server
|
|
51
|
+
# @param name [String] Tool name
|
|
52
|
+
# @return [Tool, nil] Removed tool
|
|
53
|
+
def remove_tool(name)
|
|
54
|
+
@tools.delete(name.to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Handle an MCP message
|
|
58
|
+
# @param message [Hash] MCP JSON-RPC message
|
|
59
|
+
# @return [Hash] MCP JSON-RPC response
|
|
60
|
+
def handle_message(message)
|
|
61
|
+
method = message["method"]
|
|
62
|
+
params = message["params"] || {}
|
|
63
|
+
id = message["id"]
|
|
64
|
+
|
|
65
|
+
result = case method
|
|
66
|
+
when "initialize"
|
|
67
|
+
handle_initialize(params)
|
|
68
|
+
when "tools/list"
|
|
69
|
+
handle_tools_list(params)
|
|
70
|
+
when "tools/call"
|
|
71
|
+
handle_tools_call(params)
|
|
72
|
+
when "notifications/initialized"
|
|
73
|
+
# Acknowledgement, no response needed
|
|
74
|
+
return nil
|
|
75
|
+
else
|
|
76
|
+
return jsonrpc_error(id, -32601, "Method not found: #{method}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
jsonrpc_response(id, result)
|
|
80
|
+
rescue => e
|
|
81
|
+
jsonrpc_error(id, -32603, "Internal error: #{e.message}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get MCP server configuration for options
|
|
85
|
+
# @return [Hash]
|
|
86
|
+
def to_config
|
|
87
|
+
{ type: "sdk", name: @name, instance: self }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def handle_initialize(params)
|
|
93
|
+
{
|
|
94
|
+
protocolVersion: "2024-11-05",
|
|
95
|
+
capabilities: {
|
|
96
|
+
tools: {}
|
|
97
|
+
},
|
|
98
|
+
serverInfo: {
|
|
99
|
+
name: @name,
|
|
100
|
+
version: ClaudeAgent::VERSION
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def handle_tools_list(params)
|
|
106
|
+
{
|
|
107
|
+
tools: @tools.values.map(&:to_mcp_definition)
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def handle_tools_call(params)
|
|
112
|
+
tool_name = params["name"]
|
|
113
|
+
arguments = params["arguments"] || {}
|
|
114
|
+
|
|
115
|
+
tool = @tools[tool_name]
|
|
116
|
+
unless tool
|
|
117
|
+
return {
|
|
118
|
+
content: [ { type: "text", text: "Unknown tool: #{tool_name}" } ],
|
|
119
|
+
isError: true
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
tool.call(arguments)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def jsonrpc_response(id, result)
|
|
127
|
+
{
|
|
128
|
+
jsonrpc: "2.0",
|
|
129
|
+
id: id,
|
|
130
|
+
result: result
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def jsonrpc_error(id, code, message)
|
|
135
|
+
{
|
|
136
|
+
jsonrpc: "2.0",
|
|
137
|
+
id: id,
|
|
138
|
+
error: {
|
|
139
|
+
code: code,
|
|
140
|
+
message: message
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Convenience method to create a tool
|
|
147
|
+
#
|
|
148
|
+
# @example
|
|
149
|
+
# tool = ClaudeAgent::MCP.tool("greet", "Greet someone", {name: String}) do |args|
|
|
150
|
+
# "Hello, #{args['name']}!"
|
|
151
|
+
# end
|
|
152
|
+
#
|
|
153
|
+
def self.tool(name, description, schema = {}, &handler)
|
|
154
|
+
Tool.new(name: name, description: description, schema: schema, &handler)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Convenience method to create a server
|
|
158
|
+
#
|
|
159
|
+
# @example
|
|
160
|
+
# server = ClaudeAgent::MCP.create_server(name: "mytools", tools: [tool1, tool2])
|
|
161
|
+
#
|
|
162
|
+
def self.create_server(name:, tools: [])
|
|
163
|
+
Server.new(name: name, tools: tools)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeAgent
|
|
4
|
+
module MCP
|
|
5
|
+
# Defines a tool that can be called by Claude
|
|
6
|
+
#
|
|
7
|
+
# @example Simple tool
|
|
8
|
+
# tool = ClaudeAgent::MCP::Tool.new(
|
|
9
|
+
# name: "greet",
|
|
10
|
+
# description: "Greet a person",
|
|
11
|
+
# schema: {name: String}
|
|
12
|
+
# ) do |args|
|
|
13
|
+
# "Hello, #{args['name']}!"
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example Tool with complex schema
|
|
17
|
+
# tool = ClaudeAgent::MCP::Tool.new(
|
|
18
|
+
# name: "calculate",
|
|
19
|
+
# description: "Perform arithmetic",
|
|
20
|
+
# schema: {
|
|
21
|
+
# type: "object",
|
|
22
|
+
# properties: {
|
|
23
|
+
# operation: {type: "string", enum: ["add", "subtract"]},
|
|
24
|
+
# a: {type: "number"},
|
|
25
|
+
# b: {type: "number"}
|
|
26
|
+
# },
|
|
27
|
+
# required: ["operation", "a", "b"]
|
|
28
|
+
# }
|
|
29
|
+
# ) do |args|
|
|
30
|
+
# case args["operation"]
|
|
31
|
+
# when "add" then args["a"] + args["b"]
|
|
32
|
+
# when "subtract" then args["a"] - args["b"]
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
class Tool
|
|
37
|
+
attr_reader :name, :description, :schema, :handler
|
|
38
|
+
|
|
39
|
+
# @param name [String] Tool name (must be unique within server)
|
|
40
|
+
# @param description [String] Description of what the tool does
|
|
41
|
+
# @param schema [Hash] Input schema (simple Ruby types or JSON Schema)
|
|
42
|
+
# @param handler [Proc] Block to execute when tool is called
|
|
43
|
+
def initialize(name:, description:, schema: {}, &handler)
|
|
44
|
+
@name = name.to_s
|
|
45
|
+
@description = description.to_s
|
|
46
|
+
@schema = normalize_schema(schema)
|
|
47
|
+
@handler = handler || ->(args) { raise "No handler defined for tool #{name}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Call the tool with arguments
|
|
51
|
+
# @param args [Hash] Tool arguments
|
|
52
|
+
# @return [Hash] MCP response format
|
|
53
|
+
def call(args)
|
|
54
|
+
result = @handler.call(args)
|
|
55
|
+
format_result(result)
|
|
56
|
+
rescue => e
|
|
57
|
+
format_error(e)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Convert to MCP tool definition format
|
|
61
|
+
# @return [Hash]
|
|
62
|
+
def to_mcp_definition
|
|
63
|
+
{
|
|
64
|
+
name: @name,
|
|
65
|
+
description: @description,
|
|
66
|
+
inputSchema: @schema
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Normalize schema from simple Ruby types to JSON Schema
|
|
73
|
+
def normalize_schema(schema)
|
|
74
|
+
return schema if json_schema?(schema)
|
|
75
|
+
|
|
76
|
+
# Convert simple {name: Type} format to JSON Schema
|
|
77
|
+
if schema.is_a?(Hash) && schema.values.all? { |v| v.is_a?(Class) || v.is_a?(Module) }
|
|
78
|
+
{
|
|
79
|
+
type: "object",
|
|
80
|
+
properties: schema.transform_values { |type| type_to_schema(type) },
|
|
81
|
+
required: schema.keys.map(&:to_s)
|
|
82
|
+
}
|
|
83
|
+
else
|
|
84
|
+
schema
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def json_schema?(schema)
|
|
89
|
+
return false unless schema.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
schema.key?(:type) || schema.key?("type") ||
|
|
92
|
+
schema.key?(:properties) || schema.key?("properties")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def type_to_schema(type)
|
|
96
|
+
case type.to_s
|
|
97
|
+
when "String"
|
|
98
|
+
{ type: "string" }
|
|
99
|
+
when "Integer"
|
|
100
|
+
{ type: "integer" }
|
|
101
|
+
when "Float", "Numeric"
|
|
102
|
+
{ type: "number" }
|
|
103
|
+
when "TrueClass", "FalseClass"
|
|
104
|
+
{ type: "boolean" }
|
|
105
|
+
when "Array"
|
|
106
|
+
{ type: "array" }
|
|
107
|
+
when "Hash"
|
|
108
|
+
{ type: "object" }
|
|
109
|
+
else
|
|
110
|
+
{ type: "string" }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def format_result(result)
|
|
115
|
+
content = case result
|
|
116
|
+
when String
|
|
117
|
+
[ { type: "text", text: result } ]
|
|
118
|
+
when Hash
|
|
119
|
+
result[:content] || [ { type: "text", text: result.to_json } ]
|
|
120
|
+
when Array
|
|
121
|
+
result
|
|
122
|
+
else
|
|
123
|
+
[ { type: "text", text: result.to_s } ]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
{ content: content, isError: false }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def format_error(error)
|
|
130
|
+
{
|
|
131
|
+
content: [ { type: "text", text: "Error: #{error.message}" } ],
|
|
132
|
+
isError: true
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|